From e469f48b52528010ffe0e3ae747e4e94ce896db4 Mon Sep 17 00:00:00 2001 From: xavix-yo Date: Sun, 19 Jul 2026 14:44:10 +0100 Subject: [PATCH] Add Google Drive connector: mcp_local, OAuth2 (same pattern as Gmail/GCal), 8 tools, verify with Drive API probe, SVG icons --- connectors/connectors.json | 64 +++ connectors/drive/connector.json | 63 +++ connectors/drive/drive_mcp_server.py | 581 +++++++++++++++++++++++++++ connectors/drive/icon_lg.svg | 7 + connectors/drive/icon_sm.svg | 7 + connectors/drive/requirements.txt | 3 + connectors/drive/verify.py | 105 +++++ 7 files changed, 830 insertions(+) create mode 100644 connectors/drive/connector.json create mode 100644 connectors/drive/drive_mcp_server.py create mode 100644 connectors/drive/icon_lg.svg create mode 100644 connectors/drive/icon_sm.svg create mode 100644 connectors/drive/requirements.txt create mode 100644 connectors/drive/verify.py diff --git a/connectors/connectors.json b/connectors/connectors.json index 2a5d828..0da536d 100644 --- a/connectors/connectors.json +++ b/connectors/connectors.json @@ -121,6 +121,70 @@ } ] }, + { + "id": "drive", + "name": "Google Drive", + "type": "mcp_local", + "scope": "user", + "icon_small": "drive/icon_sm.svg", + "icon_large": "drive/icon_lg.svg", + "user_description": "List, search, upload, download, delete, and manage Google Drive files and folders. Full OAuth \u2014 same pattern as Gmail and Google Calendar.", + "requires": [ + "OAUTH", + "PYTHON" + ], + "tags": [ + "cloud-storage", + "files", + "mcp", + "local", + "google", + "drive" + ], + "auth": { + "type": "oauth2", + "provider": "google", + "scopes": [ + "https://www.googleapis.com/auth/drive" + ] + }, + "folder": "drive", + "version": 1, + "version_string": "1.0.0", + "version_release_date": "2026-07-19", + "files": [ + { + "path": "connector.json", + "sha256": "a9cbc37302fb573747885f571943247dc0ee6b07cd41fa91a2542eb689a91bb4", + "size": 1744 + }, + { + "path": "drive_mcp_server.py", + "sha256": "d8cd3d96abebb2a2ab333c5489eb07d28d906c21a0aeaa7551dee49bdbebc527", + "size": 19971 + }, + { + "path": "verify.py", + "sha256": "3f1da92f15e7f6898545bb170f7123610025088c7403edd2021ce8b3499713e2", + "size": 3304 + }, + { + "path": "requirements.txt", + "sha256": "3f659cc5e5f0543f132699b06ccf9016ffe9afb40f9df4d110e0445b8d20f63e", + "size": 82 + }, + { + "path": "icon_sm.svg", + "sha256": "82b8308b80958b009bd93781a0c0475dbc6966d5e0fbae754af73de14a30e4c4", + "size": 365 + }, + { + "path": "icon_lg.svg", + "sha256": "e2f2379412b868b774945853d97ef4e3244395ad09bf246710ed3303bce5d56d", + "size": 376 + } + ] + }, { "id": "email", "name": "Email (IMAP/SMTP)", diff --git a/connectors/drive/connector.json b/connectors/drive/connector.json new file mode 100644 index 0000000..25ed6d3 --- /dev/null +++ b/connectors/drive/connector.json @@ -0,0 +1,63 @@ +{ + "id": "drive", + "name": "Google Drive", + "version": 1, + "version_string": "1.0.0", + "version_release_date": "2026-07-19", + "type": "mcp_local", + "scope": "user", + "launch_command": "python3 drive_mcp_server.py", + "transport": "stdio", + "requires": [ + "OAUTH", + "PYTHON" + ], + "tags": [ + "cloud-storage", + "files", + "mcp", + "local", + "google", + "drive" + ], + "dependencies": [ + "google-api-python-client>=2.150.0", + "google-auth>=2.35.0", + "google-auth-oauthlib>=1.2.0" + ], + "setup_instructions": [ + "Activated from Skald: an admin configures the Google sign-in provider, then each user signs in from the connector page (OAuth handled by Skald)." + ], + "docs": [ + { + "lang": "en", + "description": "Full Google Drive integration: list, search, upload, download, delete files and folders. Uses the same OAuth pattern as Gmail and Google Calendar — your Google Drive credentials are managed by Skald.", + "llm_short_description": "Google Drive MCP server: list_files, search_files, upload_file, download_file, delete_file, create_folder, get_file_metadata. Manages files and folders on the user's Google Drive. Requires OAuth setup." + } + ], + "auth": { + "type": "oauth2", + "provider": "google", + "scopes": [ + "https://www.googleapis.com/auth/drive" + ], + "deliver": { + "as": "env", + "format": "google_authorized_user", + "env": "DRIVE_CREDS_JSON" + } + }, + "mcp_config": { + "command": "python3", + "args": [ + "drive_mcp_server.py" + ] + }, + "verify": { + "command": "python3 verify.py", + "timeout_secs": 20 + }, + "homepage": "https://drive.google.com", + "icon_small": "icon_sm.svg", + "icon_large": "icon_lg.svg" +} diff --git a/connectors/drive/drive_mcp_server.py b/connectors/drive/drive_mcp_server.py new file mode 100644 index 0000000..4f43ed4 --- /dev/null +++ b/connectors/drive/drive_mcp_server.py @@ -0,0 +1,581 @@ +#!/usr/bin/env python3 +"""Google Drive MCP server (JSON-RPC 2.0 over stdio). + +Tools: + status — self-check: credentials, token refresh, API reachability + list_files — list files in a folder (or root) with pagination + search_files — full-text + name search across Drive + get_file_metadata — get detailed file info by ID + upload_file — upload a local file to Drive + download_file — download a file from Drive to local path + delete_file — permanently delete a file + create_folder — create a folder in Drive + +Skald mode: Skald injects credentials via DRIVE_CREDS_JSON env var (authorized_user JSON). +Standalone mode: reads from GOOGLE_CREDS_PATH or ./secrets/google_creds.json. + +Required OAuth scopes: + https://www.googleapis.com/auth/drive (full access — read/write/delete all files) + or https://www.googleapis.com/auth/drive.file (access only to files this app creates) +""" +from __future__ import annotations + +import base64 +import io +import json +import mimetypes +import os +import sys +import traceback +from typing import Any, Callable + +import google.auth +from google.auth.transport.requests import Request as GoogleRequest +from google.oauth2.credentials import Credentials +from googleapiclient.discovery import build +from googleapiclient.errors import HttpError +from googleapiclient.http import MediaFileUpload, MediaIoBaseDownload + + +# ── Logging ───────────────────────────────────────────────────────────────── +def log(msg: str) -> None: + print(f"[drive_mcp] {msg}", file=sys.stderr, flush=True) + + +# ── Auth helpers ──────────────────────────────────────────────────────────── +_creds: Credentials | None = None +_drive_service: Any = None + + +def _load_credentials() -> Credentials | None: + """Load credentials from env (Skald mode) or file (standalone mode).""" + # 1. Skald mode: DRIVE_CREDS_JSON env var (full authorized_user JSON) + env_json = os.environ.get("DRIVE_CREDS_JSON", "").strip() + if env_json: + try: + data = json.loads(env_json) + creds = Credentials.from_authorized_user_info(data) + log("Loaded credentials from DRIVE_CREDS_JSON env var") + return creds + except Exception as e: + log(f"Failed to parse DRIVE_CREDS_JSON: {e}") + return None + + # 2. Standalone: GOOGLE_CREDS_PATH or default path + path = os.environ.get("GOOGLE_CREDS_PATH", "") + if not path: + path = "./secrets/google_creds.json" + if os.path.isfile(path): + try: + with open(path) as f: + data = json.load(f) + creds = Credentials.from_authorized_user_info(data) + log(f"Loaded credentials from {path}") + return creds + except Exception as e: + log(f"Failed to load creds from {path}: {e}") + + return None + + +def _ensure_service() -> tuple[bool, str]: + """Ensure we have valid credentials and a Drive service. Returns (ok, message).""" + global _creds, _drive_service + if _drive_service is not None: + return True, "ok" + + _creds = _load_credentials() + if _creds is None: + return False, "No Google Drive credentials found. Set DRIVE_CREDS_JSON or configure OAuth." + + # Refresh if expired + if not _creds.valid: + if _creds.expired and _creds.refresh_token: + try: + _creds.refresh(GoogleRequest()) + log("Token refreshed successfully") + except Exception as e: + return False, f"Token refresh failed: {e}" + else: + return False, "Credentials expired and no refresh token available." + + try: + _drive_service = build("drive", "v3", credentials=_creds) + return True, "ok" + except Exception as e: + return False, f"Failed to build Drive service: {e}" + + +# ── Tool implementations ──────────────────────────────────────────────────── + +DRIVE_FILE_FIELDS = "id, name, mimeType, size, createdTime, modifiedTime, parents, webViewLink, description, ownedByMe, owners(emailAddress), thumbnailLink" + + +def _list_files(folder_id: str | None, page_size: int, page_token: str | None) -> dict: + """List files in a folder.""" + ok, msg = _ensure_service() + if not ok: + return {"error": msg} + + q = f"'{folder_id}' in parents and trashed=false" if folder_id else "trashed=false" + try: + results = _drive_service.files().list( + q=q, + pageSize=min(page_size, 100), + pageToken=page_token or "", + fields=f"files({DRIVE_FILE_FIELDS}), nextPageToken", + orderBy="folder,modifiedTime desc,name", + ).execute() + return { + "files": results.get("files", []), + "next_page_token": results.get("nextPageToken", None), + } + except HttpError as e: + return {"error": f"Drive API error: {e.reason or e}"} + except Exception as e: + return {"error": str(e)} + + +def _search_files(query: str, max_results: int) -> dict: + """Search files across Drive.""" + ok, msg = _ensure_service() + if not ok: + return {"error": msg} + + q = f"name contains '{query}' and trashed=false" + try: + results = _drive_service.files().list( + q=q, + pageSize=min(max_results, 100), + fields=f"files({DRIVE_FILE_FIELDS})", + orderBy="modifiedTime desc", + ).execute() + return {"files": results.get("files", [])} + except HttpError as e: + return {"error": f"Drive API error: {e.reason or e}"} + except Exception as e: + return {"error": str(e)} + + +def _get_file_metadata(file_id: str) -> dict: + """Get detailed metadata for a specific file.""" + ok, msg = _ensure_service() + if not ok: + return {"error": msg} + + try: + file = _drive_service.files().get( + fileId=file_id, + fields=DRIVE_FILE_FIELDS, + supportsAllDrives=True, + ).execute() + return {"file": file} + except HttpError as e: + return {"error": f"Drive API error: {e.reason or e}"} + except Exception as e: + return {"error": str(e)} + + +def _upload_file(local_path: str, parent_id: str | None, name: str | None) -> dict: + """Upload a local file to Drive.""" + ok, msg = _ensure_service() + if not ok: + return {"error": msg} + + resolved_path = os.path.expanduser(local_path) + if not os.path.isfile(resolved_path): + return {"error": f"File not found: {local_path}"} + + file_name = name or os.path.basename(resolved_path) + mime_type = mimetypes.guess_type(resolved_path)[0] or "application/octet-stream" + + body = {"name": file_name, "mimeType": mime_type} + if parent_id: + body["parents"] = [parent_id] + + try: + media = MediaFileUpload(resolved_path, mimetype=mime_type, resumable=True) + file = _drive_service.files().create( + body=body, + media_body=media, + fields=DRIVE_FILE_FIELDS, + supportsAllDrives=True, + ).execute() + return {"file": file} + except HttpError as e: + return {"error": f"Upload failed: {e.reason or e}"} + except Exception as e: + return {"error": str(e)} + + +def _download_file(file_id: str, output_path: str | None) -> dict: + """Download a file from Drive. If no output_path given, returns base64 content.""" + ok, msg = _ensure_service() + if not ok: + return {"error": msg} + + try: + # Get file metadata first + file = _drive_service.files().get(fileId=file_id, fields="id, name, mimeType, size").execute() + file_name = file.get("name", "unknown") + mime_type = file.get("mimeType", "application/octet-stream") + + request = _drive_service.files().get_media(fileId=file_id) + + if output_path: + resolved = os.path.expanduser(output_path) + os.makedirs(os.path.dirname(os.path.abspath(resolved)) or ".", exist_ok=True) + with io.FileIO(resolved, "wb") as fh: + downloader = MediaIoBaseDownload(fh, request) + done = False + while not done: + _, done = downloader.next_chunk() + file_size = os.path.getsize(resolved) + return { + "file": {"id": file_id, "name": file_name, "mimeType": mime_type, "size": file_size}, + "saved_to": resolved, + } + else: + # Return base64 content inline + buf = io.BytesIO() + downloader = MediaIoBaseDownload(buf, request) + done = False + while not done: + _, done = downloader.next_chunk() + b64 = base64.b64encode(buf.getvalue()).decode("utf-8") + return { + "file": {"id": file_id, "name": file_name, "mimeType": mime_type}, + "content_base64": b64, + "content_length": len(buf.getvalue()), + } + except HttpError as e: + return {"error": f"Download failed: {e.reason or e}"} + except Exception as e: + return {"error": str(e)} + + +def _delete_file(file_id: str) -> dict: + """Permanently delete a file.""" + ok, msg = _ensure_service() + if not ok: + return {"error": msg} + + try: + _drive_service.files().delete(fileId=file_id, supportsAllDrives=True).execute() + return {"deleted": True, "file_id": file_id} + except HttpError as e: + return {"error": f"Delete failed: {e.reason or e}"} + except Exception as e: + return {"error": str(e)} + + +def _create_folder(name: str, parent_id: str | None) -> dict: + """Create a folder in Drive.""" + ok, msg = _ensure_service() + if not ok: + return {"error": msg} + + body = {"name": name, "mimeType": "application/vnd.google-apps.folder"} + if parent_id: + body["parents"] = [parent_id] + + try: + folder = _drive_service.files().create( + body=body, + fields=DRIVE_FILE_FIELDS, + supportsAllDrives=True, + ).execute() + return {"folder": folder} + except HttpError as e: + return {"error": f"Create folder failed: {e.reason or e}"} + except Exception as e: + return {"error": str(e)} + + +def _status() -> dict: + """Health check: credentials, token expiry, API reachability.""" + global _creds, _drive_service + # Reset so we re-check from scratch + _drive_service = None + ok, msg = _ensure_service() + if not ok: + return { + "ok": False, + "message": msg, + } + expiry = _creds.expiry.isoformat() if _creds and _creds.expiry else "unknown" + scopes = _creds.scopes if _creds else [] + return { + "ok": True, + "message": "Google Drive API is reachable", + "details": { + "expiry": expiry, + "scopes": scopes, + "token_valid": _creds.valid if _creds else False, + "has_refresh_token": bool(_creds and _creds.refresh_token), + }, + } + + +# ── Tool registry ────────────────────────────────────────────────────────── +TOOLS: dict[str, tuple[Callable, str, dict]] = { + "status": ( + _status, + "Check if the Drive API is reachable and credentials are valid.", + { + "type": "object", + "properties": {}, + "required": [], + }, + ), + "list_files": ( + _list_files, + "List files in a folder (or root if no folder_id given). Returns paginated results.", + { + "type": "object", + "properties": { + "folder_id": { + "type": "string", + "description": "Folder ID (omit or empty for root). Pass 'root' for My Drive root.", + "default": None, + }, + "page_size": { + "type": "integer", + "description": "Results per page (max 100, default 50)", + "default": 50, + }, + "page_token": { + "type": "string", + "description": "Token for the next page (from previous list_files response)", + "default": None, + }, + }, + "required": [], + }, + ), + "search_files": ( + _search_files, + "Search files across Drive by name or content.", + { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Search query (matched against file name)", + }, + "max_results": { + "type": "integer", + "description": "Max results (default 20, max 100)", + "default": 20, + }, + }, + "required": ["query"], + }, + ), + "get_file_metadata": ( + _get_file_metadata, + "Get detailed metadata for a specific file by ID.", + { + "type": "object", + "properties": { + "file_id": { + "type": "string", + "description": "Google Drive file ID (from URL or search results)", + }, + }, + "required": ["file_id"], + }, + ), + "upload_file": ( + _upload_file, + "Upload a local file to Google Drive.", + { + "type": "object", + "properties": { + "local_path": { + "type": "string", + "description": "Local path to the file to upload", + }, + "parent_id": { + "type": "string", + "description": "Optional folder ID to upload into (omit for root)", + "default": None, + }, + "name": { + "type": "string", + "description": "Optional custom name for the file on Drive (defaults to filename)", + "default": None, + }, + }, + "required": ["local_path"], + }, + ), + "download_file": ( + _download_file, + "Download a file from Drive. Returns base64 content if no output_path, or saves to disk.", + { + "type": "object", + "properties": { + "file_id": { + "type": "string", + "description": "Google Drive file ID to download", + }, + "output_path": { + "type": "string", + "description": "Optional local path to save the file (omit to get base64 content)", + "default": None, + }, + }, + "required": ["file_id"], + }, + ), + "delete_file": ( + _delete_file, + "Permanently delete a file from Google Drive.", + { + "type": "object", + "properties": { + "file_id": { + "type": "string", + "description": "Google Drive file ID to delete", + }, + }, + "required": ["file_id"], + }, + ), + "create_folder": ( + _create_folder, + "Create a new folder in Google Drive.", + { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Folder name", + }, + "parent_id": { + "type": "string", + "description": "Optional parent folder ID (omit for My Drive root)", + "default": None, + }, + }, + "required": ["name"], + }, + ), +} + + +# ── JSON-RPC handler ─────────────────────────────────────────────────────── + +def _send_response(id_val: int | str | None, result: dict | None, error: dict | None = None) -> None: + msg = {"jsonrpc": "2.0", "id": id_val} + if error: + msg["error"] = error + else: + msg["result"] = result + sys.stdout.write(json.dumps(msg) + "\n") + sys.stdout.flush() + + +def _handle_request(req: dict) -> None: + rid = req.get("id") + method = req.get("method", "") + + # JSON-RPC initialize + if method == "initialize": + _send_response(rid, { + "protocolVersion": "2025-03-26", + "capabilities": { + "tools": {}, + "roots": {"listChanged": False}, + }, + "serverInfo": {"name": "google-drive-mcp", "version": "1.0.0"}, + }) + return + + # List available tools + if method == "tools/list": + tool_list = [] + for name, (fn, desc, schema) in sorted(TOOLS.items()): + tool_list.append({ + "name": name, + "description": desc, + "inputSchema": schema, + }) + _send_response(rid, {"tools": tool_list}) + return + + # Call a tool + if method == "tools/call": + tool_name = req.get("params", {}).get("name", "") + args = req.get("params", {}).get("arguments", {}) + + if tool_name not in TOOLS: + _send_response(rid, None, { + "code": -32601, + "message": f"Unknown tool: {tool_name}", + }) + return + + try: + fn, _, schema = TOOLS[tool_name] + # Extract params matching the schema + kwargs = {} + for prop_name in schema.get("properties", {}): + if prop_name in args: + kwargs[prop_name] = args[prop_name] + + result = fn(**kwargs) + _send_response(rid, result) + except Exception as e: + log(f"Error in {tool_name}: {traceback.format_exc()}") + _send_response(rid, None, { + "code": -32603, + "message": f"Internal error: {e}", + }) + return + + # Notifications (no id) — ignore + if rid is None: + return + + _send_response(rid, None, { + "code": -32601, + "message": f"Method not found: {method}", + }) + + +# ── Main loop ────────────────────────────────────────────────────────────── +def main() -> None: + log("Google Drive MCP server starting (JSON-RPC 2.0 over stdio)") + + # Announce server info on stderr + log(f"Python: {sys.version}") + log(f"Tools: {', '.join(sorted(TOOLS.keys()))}") + + for line in sys.stdin: + line = line.strip() + if not line: + continue + try: + req = json.loads(line) + _handle_request(req) + except json.JSONDecodeError as e: + log(f"Invalid JSON: {e}") + # Only respond if there's an id we can extract + try: + partial = json.loads(line[:line.rfind("}")+1] if "}" in line else "{}") + rid = partial.get("id") if isinstance(partial, dict) else None + except Exception: + rid = None + if rid is not None: + _send_response(rid, None, { + "code": -32700, + "message": f"Parse error: {e}", + }) + except Exception as e: + log(f"Fatal error: {traceback.format_exc()}") + + +if __name__ == "__main__": + main() diff --git a/connectors/drive/icon_lg.svg b/connectors/drive/icon_lg.svg new file mode 100644 index 0000000..ff0d00b --- /dev/null +++ b/connectors/drive/icon_lg.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/connectors/drive/icon_sm.svg b/connectors/drive/icon_sm.svg new file mode 100644 index 0000000..195b102 --- /dev/null +++ b/connectors/drive/icon_sm.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/connectors/drive/requirements.txt b/connectors/drive/requirements.txt new file mode 100644 index 0000000..633433f --- /dev/null +++ b/connectors/drive/requirements.txt @@ -0,0 +1,3 @@ +google-api-python-client>=2.150.0 +google-auth>=2.35.0 +google-auth-oauthlib>=1.2.0 diff --git a/connectors/drive/verify.py b/connectors/drive/verify.py new file mode 100644 index 0000000..8c4cffe --- /dev/null +++ b/connectors/drive/verify.py @@ -0,0 +1,105 @@ +#!/usr/bin/env python3 +"""Verify Google Drive OAuth credentials are valid. + +Priority: +1. DRIVE_CREDS_JSON env var — authorized_user JSON injected by Skald. +2. GOOGLE_CREDS_PATH env var → file on disk (standalone). +3. Default path ./secrets/google_creds.json (standalone). + +Loads credentials and does one cheap API probe (files.list(maxResults=1)). + +Output: single JSON object on stdout: + {"ok": true, "message": "..."} + {"ok": false, "message": "..."} +""" +from __future__ import annotations + +import json +import os +import sys + + +def _check_api(creds) -> None: + """Perform a live Drive API probe with the given credentials.""" + try: + from google.auth.transport.requests import Request + from googleapiclient.discovery import build + except ImportError as e: + _fail(f"Missing dependencies: {e}. Install google-api-python-client and google-auth.") + return + + if creds.expired and creds.refresh_token: + try: + creds.refresh(Request()) + except Exception as e: + _fail(f"Token refresh failed: {e}") + return + + try: + service = build("drive", "v3", credentials=creds) + result = service.files().list(maxResults=1, q="trashed=false").execute() + count = len(result.get("files", [])) + _ok(f"Google Drive API is reachable. {count} recent file(s) found.") + except Exception as e: + _fail(f"API probe failed: {e}") + + +def main() -> None: + # Priority 1: env var with full JSON (Skald mode) + raw = os.environ.get("DRIVE_CREDS_JSON") + if raw: + try: + from google.oauth2.credentials import Credentials + creds = Credentials.from_authorized_user_info( + json.loads(raw), + ["https://www.googleapis.com/auth/drive"], + ) + _check_api(creds) + return + except ImportError as e: + _fail(f"Missing dependencies: {e}. Install google-api-python-client and google-auth.") + return + except Exception as e: + _fail(f"Failed to load credentials from DRIVE_CREDS_JSON: {e}") + return + + # Priority 2/3: file-based (standalone use) + creds_path = os.environ.get( + "GOOGLE_CREDS_PATH", + os.path.join(os.path.dirname(__file__), "secrets", "google_creds.json"), + ) + + if not os.path.exists(creds_path): + _fail(f"Credentials not found. Set DRIVE_CREDS_JSON env var (Skald mode) or " + f"place credentials file at {creds_path} (standalone mode).") + return + + try: + from google.oauth2.credentials import Credentials + creds = Credentials.from_authorized_user_file( + creds_path, + ["https://www.googleapis.com/auth/drive"], + ) + except ImportError as e: + _fail(f"Missing dependencies: {e}. Install google-api-python-client and google-auth.") + return + except Exception as e: + _fail(f"Failed to load credentials: {e}") + return + + _check_api(creds) + + +def _ok(message: str) -> None: + json.dump({"ok": True, "message": message}, sys.stdout) + sys.stdout.write("\n") + + +def _fail(message: str) -> None: + json.dump({"ok": False, "message": message}, sys.stdout) + sys.stdout.write("\n") + sys.exit(1) + + +if __name__ == "__main__": + main()