Skip to content

Commit 8fee158

Browse files
committed
added basic workplace tools
1 parent 4f2bf38 commit 8fee158

File tree

9 files changed

+982
-0
lines changed

9 files changed

+982
-0
lines changed

.env.example

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,4 @@
11
MCP_SERVER_URL=http://localhost:8081
2+
3+
GOOGLE_OAUTH_CLIENT_ID=your-client-id.apps.googleusercontent.com
4+
GOOGLE_OAUTH_CLIENT_SECRET=GOCSPX-your-client-secret

pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ dependencies = [
1414
"tavily-python>=0.7.13",
1515
"markitdown[all]>=0.1.4",
1616
"black>=25.12.0",
17+
"google-api-python-client>=2.168.0",
18+
"google-auth-httplib2>=0.2.0",
19+
"google-auth-oauthlib>=1.2.2",
1720
]
1821

1922
[dependency-groups]

src/server.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,27 @@ def _register_toolset(name: str, loader: Callable[[FastMCP], None]) -> None:
6969
except ImportError as e:
7070
logger.warning("PDF to Markdown tools module missing: %s", e)
7171

72+
try:
73+
from src.tools.google import gmail
74+
75+
_register_toolset("gmail", gmail.register_tools)
76+
except ImportError as e:
77+
logger.warning("Gmail tools module missing: %s", e)
78+
79+
try:
80+
from src.tools.google import calendar
81+
82+
_register_toolset("calendar", calendar.register_tools)
83+
except ImportError as e:
84+
logger.warning("Google Calendar tools module missing: %s", e)
85+
86+
try:
87+
from src.tools.google import drive
88+
89+
_register_toolset("drive", drive.register_tools)
90+
except ImportError as e:
91+
logger.warning("Google Drive tools module missing: %s", e)
92+
7293

7394
tool_count = len(getattr(getattr(mcp, "_tool_manager", None), "tools", []))
7495
logger.info("MCP server initialized with %d tools", tool_count)

src/tools/google/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
# Google Workspace Tools

src/tools/google/auth.py

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
import json
2+
import logging
3+
import os
4+
from pathlib import Path
5+
6+
from google.auth.transport.requests import Request
7+
from google.oauth2.credentials import Credentials
8+
from google_auth_oauthlib.flow import InstalledAppFlow
9+
from googleapiclient.discovery import build
10+
11+
logger = logging.getLogger("humcp.google.auth")
12+
13+
TOKEN_PATH = Path.home() / ".humcp" / "google_token.json"
14+
CLIENT_SECRET_PATH = Path.home() / ".humcp" / "client_secret.json"
15+
16+
SCOPES = {
17+
"gmail_readonly": "https://www.googleapis.com/auth/gmail.readonly",
18+
"gmail_send": "https://www.googleapis.com/auth/gmail.send",
19+
"gmail_modify": "https://www.googleapis.com/auth/gmail.modify",
20+
"calendar": "https://www.googleapis.com/auth/calendar",
21+
"calendar_readonly": "https://www.googleapis.com/auth/calendar.readonly",
22+
"drive": "https://www.googleapis.com/auth/drive",
23+
"drive_readonly": "https://www.googleapis.com/auth/drive.readonly",
24+
"drive_file": "https://www.googleapis.com/auth/drive.file",
25+
}
26+
27+
def _ensure_config_dir() -> None:
28+
"""Ensure the config directory exists."""
29+
TOKEN_PATH.parent.mkdir(parents=True, exist_ok=True)
30+
31+
32+
def load_client_config() -> dict | None:
33+
"""Load OAuth client configuration from environment or file."""
34+
client_id = os.getenv("GOOGLE_OAUTH_CLIENT_ID")
35+
client_secret = os.getenv("GOOGLE_OAUTH_CLIENT_SECRET")
36+
37+
if client_id and client_secret:
38+
logger.debug("Using OAuth credentials from environment variables")
39+
return {
40+
"installed": {
41+
"client_id": client_id,
42+
"client_secret": client_secret,
43+
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
44+
"token_uri": "https://oauth2.googleapis.com/token",
45+
"redirect_uris": ["http://localhost"],
46+
}
47+
}
48+
49+
if CLIENT_SECRET_PATH.exists():
50+
logger.debug("Using OAuth credentials from %s", CLIENT_SECRET_PATH)
51+
with open(CLIENT_SECRET_PATH) as f:
52+
return json.load(f)
53+
54+
return None
55+
56+
57+
def get_credentials(scopes: list[str]) -> Credentials | None:
58+
"""Get valid credentials, refreshing or re-authenticating as needed."""
59+
_ensure_config_dir()
60+
creds = None
61+
62+
# Load existing token if available
63+
if TOKEN_PATH.exists():
64+
try:
65+
creds = Credentials.from_authorized_user_file(str(TOKEN_PATH), scopes)
66+
logger.debug("Loaded existing credentials from token file")
67+
except Exception as e:
68+
logger.warning("Failed to load existing token: %s", e)
69+
creds = None
70+
71+
# Refresh or get new credentials
72+
if creds and creds.expired and creds.refresh_token:
73+
try:
74+
logger.info("Refreshing expired credentials")
75+
creds.refresh(Request())
76+
_save_credentials(creds)
77+
except Exception as e:
78+
logger.warning("Failed to refresh token: %s", e)
79+
creds = None
80+
81+
if not creds or not creds.valid:
82+
creds = _run_auth_flow(scopes)
83+
84+
return creds
85+
86+
87+
def _run_auth_flow(scopes: list[str]) -> Credentials | None:
88+
"""Run the OAuth authorization flow."""
89+
client_config = load_client_config()
90+
if not client_config:
91+
logger.error(
92+
"Google OAuth not configured. Set GOOGLE_OAUTH_CLIENT_ID and "
93+
"GOOGLE_OAUTH_CLIENT_SECRET environment variables, or create "
94+
"%s",
95+
CLIENT_SECRET_PATH,
96+
)
97+
return None
98+
99+
try:
100+
# Allow http for localhost during development
101+
os.environ["OAUTHLIB_INSECURE_TRANSPORT"] = "1"
102+
103+
flow = InstalledAppFlow.from_client_config(client_config, scopes)
104+
logger.info("Opening browser for Google authentication...")
105+
creds = flow.run_local_server(port=0)
106+
107+
_save_credentials(creds)
108+
logger.info("Authentication successful")
109+
return creds
110+
111+
except Exception as e:
112+
logger.error("OAuth flow failed: %s", e)
113+
return None
114+
115+
116+
def _save_credentials(creds: Credentials) -> None:
117+
"""Save credentials to token file."""
118+
_ensure_config_dir()
119+
with open(TOKEN_PATH, "w") as f:
120+
f.write(creds.to_json())
121+
logger.debug("Credentials saved to %s", TOKEN_PATH)
122+
123+
124+
def get_google_service(service_name: str, version: str, scopes: list[str]):
125+
"""Build an authenticated Google API service client."""
126+
creds = get_credentials(scopes)
127+
if not creds:
128+
raise ValueError(
129+
"Google authentication failed. Please configure OAuth credentials:\n"
130+
"1. Set GOOGLE_OAUTH_CLIENT_ID and GOOGLE_OAUTH_CLIENT_SECRET env vars\n"
131+
"2. Or create ~/.humcp/client_secret.json with OAuth credentials"
132+
)
133+
134+
return build(service_name, version, credentials=creds)
135+
136+
137+
def check_auth_status() -> dict:
138+
"""Check current authentication status."""
139+
config = load_client_config()
140+
has_config = config is not None
141+
has_token = TOKEN_PATH.exists()
142+
143+
status = {
144+
"configured": has_config,
145+
"authenticated": False,
146+
"token_path": str(TOKEN_PATH),
147+
"config_source": None,
148+
}
149+
150+
if has_config:
151+
if os.getenv("GOOGLE_OAUTH_CLIENT_ID"):
152+
status["config_source"] = "environment"
153+
else:
154+
status["config_source"] = str(CLIENT_SECRET_PATH)
155+
156+
if has_token:
157+
try:
158+
creds = Credentials.from_authorized_user_file(str(TOKEN_PATH))
159+
status["authenticated"] = creds.valid or creds.refresh_token is not None
160+
if creds.expired:
161+
status["token_status"] = "expired (will refresh)"
162+
elif creds.valid:
163+
status["token_status"] = "valid"
164+
except Exception:
165+
status["token_status"] = "invalid"
166+
167+
return status

src/tools/google/calendar.py

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
import asyncio
2+
import logging
3+
from datetime import datetime, timedelta, timezone
4+
5+
from fastmcp import FastMCP
6+
7+
from src.tools.google.auth import SCOPES, get_google_service
8+
9+
logger = logging.getLogger("humcp.tools.google.calendar")
10+
11+
# Scopes required for Calendar operations
12+
CALENDAR_READONLY_SCOPES = [SCOPES["calendar_readonly"]]
13+
CALENDAR_FULL_SCOPES = [SCOPES["calendar"]]
14+
15+
16+
async def list_calendars() -> dict:
17+
"""List all calendars accessible to the user."""
18+
try:
19+
20+
def _list():
21+
service = get_google_service("calendar", "v3", CALENDAR_READONLY_SCOPES)
22+
results = service.calendarList().list().execute()
23+
calendars = results.get("items", [])
24+
return {
25+
"calendars": [
26+
{
27+
"id": cal["id"],
28+
"name": cal.get("summary", ""),
29+
"description": cal.get("description", ""),
30+
"primary": cal.get("primary", False),
31+
"access_role": cal.get("accessRole", ""),
32+
}
33+
for cal in calendars
34+
],
35+
"total": len(calendars),
36+
}
37+
38+
logger.info("calendar_list")
39+
result = await asyncio.to_thread(_list)
40+
return {"success": True, "data": result}
41+
except Exception as e:
42+
logger.exception("calendar_list failed")
43+
return {"success": False, "error": str(e)}
44+
45+
46+
async def events(
47+
calendar_id: str = "primary",
48+
days_ahead: int = 7,
49+
max_results: int = 50,
50+
) -> dict:
51+
"""List upcoming events from a calendar."""
52+
try:
53+
54+
def _list_events():
55+
service = get_google_service("calendar", "v3", CALENDAR_READONLY_SCOPES)
56+
57+
now = datetime.now(timezone.utc)
58+
time_min = now.isoformat()
59+
time_max = (now + timedelta(days=days_ahead)).isoformat()
60+
61+
results = (
62+
service.events()
63+
.list(
64+
calendarId=calendar_id,
65+
timeMin=time_min,
66+
timeMax=time_max,
67+
maxResults=max_results,
68+
singleEvents=True,
69+
orderBy="startTime",
70+
)
71+
.execute()
72+
)
73+
74+
items = results.get("items", [])
75+
return {
76+
"events": [
77+
{
78+
"id": event["id"],
79+
"title": event.get("summary", "(no title)"),
80+
"description": event.get("description", ""),
81+
"start": event.get("start", {}).get(
82+
"dateTime", event.get("start", {}).get("date", "")
83+
),
84+
"end": event.get("end", {}).get(
85+
"dateTime", event.get("end", {}).get("date", "")
86+
),
87+
"location": event.get("location", ""),
88+
"status": event.get("status", ""),
89+
"html_link": event.get("htmlLink", ""),
90+
}
91+
for event in items
92+
],
93+
"total": len(items),
94+
}
95+
96+
logger.info(
97+
"calendar_events calendar_id=%s days_ahead=%s", calendar_id, days_ahead
98+
)
99+
result = await asyncio.to_thread(_list_events)
100+
return {"success": True, "data": result}
101+
except Exception as e:
102+
logger.exception("calendar_events failed")
103+
return {"success": False, "error": str(e)}
104+
105+
106+
async def create_event(
107+
title: str,
108+
start_time: str,
109+
end_time: str,
110+
calendar_id: str = "primary",
111+
description: str = "",
112+
location: str = "",
113+
attendees: str = "",
114+
) -> dict:
115+
"""Create a new calendar event."""
116+
try:
117+
118+
def _create():
119+
service = get_google_service("calendar", "v3", CALENDAR_FULL_SCOPES)
120+
121+
event_body = {
122+
"summary": title,
123+
"description": description,
124+
"location": location,
125+
"start": {"dateTime": start_time, "timeZone": "UTC"},
126+
"end": {"dateTime": end_time, "timeZone": "UTC"},
127+
}
128+
129+
if attendees:
130+
event_body["attendees"] = [
131+
{"email": email.strip()} for email in attendees.split(",")
132+
]
133+
134+
event = (
135+
service.events()
136+
.insert(calendarId=calendar_id, body=event_body)
137+
.execute()
138+
)
139+
140+
return {
141+
"id": event["id"],
142+
"title": event.get("summary"),
143+
"start": event.get("start", {}).get("dateTime"),
144+
"end": event.get("end", {}).get("dateTime"),
145+
"html_link": event.get("htmlLink"),
146+
}
147+
148+
logger.info("calendar_create_event title=%s", title)
149+
result = await asyncio.to_thread(_create)
150+
return {"success": True, "data": result}
151+
except Exception as e:
152+
logger.exception("calendar_create_event failed")
153+
return {"success": False, "error": str(e)}
154+
155+
156+
async def delete_event(
157+
event_id: str,
158+
calendar_id: str = "primary",
159+
) -> dict:
160+
"""Delete a calendar event."""
161+
try:
162+
163+
def _delete():
164+
service = get_google_service("calendar", "v3", CALENDAR_FULL_SCOPES)
165+
service.events().delete(
166+
calendarId=calendar_id, eventId=event_id
167+
).execute()
168+
return {"deleted_event_id": event_id}
169+
170+
logger.info("calendar_delete_event event_id=%s", event_id)
171+
result = await asyncio.to_thread(_delete)
172+
return {"success": True, "data": result}
173+
except Exception as e:
174+
logger.exception("calendar_delete_event failed")
175+
return {"success": False, "error": str(e)}
176+
177+
178+
def register_tools(mcp: FastMCP) -> None:
179+
"""Register all Google Calendar tools with the MCP server."""
180+
logger.info("Registering Google Calendar tools")
181+
182+
mcp.tool(name="calendar_list")(list_calendars)
183+
mcp.tool(name="calendar_events")(events)
184+
mcp.tool(name="calendar_create_event")(create_event)
185+
mcp.tool(name="calendar_delete_event")(delete_event)

0 commit comments

Comments
 (0)