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:
@@ -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)",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
@@ -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 |
@@ -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 |
@@ -0,0 +1,3 @@
|
|||||||
|
google-api-python-client>=2.150.0
|
||||||
|
google-auth>=2.35.0
|
||||||
|
google-auth-oauthlib>=1.2.0
|
||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user