Add Google Drive connector: mcp_local, OAuth2 (same pattern as Gmail/GCal), 8 tools, verify with Drive API probe, SVG icons

This commit is contained in:
2026-07-19 14:44:10 +01:00
parent e8ce11989d
commit e469f48b52
7 changed files with 830 additions and 0 deletions
+64
View File
@@ -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", "id": "email",
"name": "Email (IMAP/SMTP)", "name": "Email (IMAP/SMTP)",
+63
View File
@@ -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"
}
+581
View File
@@ -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()
+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 96" fill="none">
<rect width="96" height="96" rx="16" fill="#4285F4"/>
<path d="M48 28L24 68h48L48 28z" fill="#FBBC04"/>
<path d="M48 28L36 52l-12 16h48l-12-16-12-16z" fill="#34A853"/>
<path d="M48 44l-12 16h24l-12-16z" fill="#4285F4"/>
<rect x="32" y="56" width="32" height="4" rx="2" fill="#EA4335"/>
</svg>

After

Width:  |  Height:  |  Size: 376 B

+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" fill="none">
<rect width="48" height="48" rx="8" fill="#4285F4"/>
<path d="M24 14L12 34h24L24 14z" fill="#FBBC04"/>
<path d="M24 14L18 26l-6 8h24l-6-8-6-8z" fill="#34A853"/>
<path d="M24 22l-6 8h12l-6-8z" fill="#4285F4"/>
<rect x="16" y="28" width="16" height="2" rx="1" fill="#EA4335"/>
</svg>

After

Width:  |  Height:  |  Size: 365 B

+3
View File
@@ -0,0 +1,3 @@
google-api-python-client>=2.150.0
google-auth>=2.35.0
google-auth-oauthlib>=1.2.0
+105
View File
@@ -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()