1+ import os
2+ import json
3+ from pathlib import Path
4+
15from langchain_mcp_adapters .client import MultiServerMCPClient
26from Auth .swiggy_auth import get_swiggy_token
3- from Auth .gmail_auth import get_gmail_token
4- from Auth .telegram_auth import get_telegram_config
5- from Auth .tavily_auth import get_tavily_config
6- from Auth .github_auth import get_github_config
7+ from Auth .google_auth import get_google_config , auto_auth
78
89from configuration import TELEGRAM_BLACKLIST
910
11+ import structlog
12+ log = structlog .get_logger ()
13+
14+ SICILY_HOME = Path .home () / ".sicily"
15+ CONNECTED_PATH = SICILY_HOME / "connected.json"
16+
17+
18+ def requires_keys (* keys : str ):
19+ """
20+ Decorator to attach required environment variables to a connector loader.
21+ Allows the UI to check for missing keys before attempting to load it.
22+ """
23+ def decorator (func ):
24+ func .required_keys = list (keys )
25+ return func
26+ return decorator
27+
28+
1029async def load_swiggy_tools (tool_manager ):
1130 token = await get_swiggy_token ()
1231
@@ -33,23 +52,36 @@ async def load_swiggy_tools(tool_manager):
3352 await tool_manager .register (im_tools , "swiggy-instamart" )
3453
3554
36- async def load_gmail_tools (tool_manager ):
37- token = await get_gmail_token ()
55+ # async def load_calendar_tools(tool_manager):
56+ # """
57+ # Local Google Calendar MCP using @cocal/google-calendar-mcp.
58+ # Works with normal personal OAuth credentials (no Developer Preview needed).
59+ # """
3860
39- gmail_client = MultiServerMCPClient ({
40- "gmail" : {
41- "transport" : "streamable_http" ,
42- "url" : "https://gmailmcp.googleapis.com/mcp/v1" ,
43- "headers" : {"Authorization" : f"Bearer { token } " },
44- }
45- })
61+ # credentials_path = str(SICILY_HOME / "google_credentials.json")
4662
47- tools = await gmail_client .get_tools ()
48- await tool_manager .register (tools , "gmail" )
63+ # calendar_client = MultiServerMCPClient({
64+ # "calendar": {
65+ # "transport": "stdio",
66+ # "command": "npx",
67+ # "args": ["-y", "@cocal/google-calendar-mcp"],
68+ # "env": {
69+ # "GOOGLE_OAUTH_CREDENTIALS": credentials_path,
70+ # },
71+ # }
72+ # })
4973
74+ # tools = await calendar_client.get_tools()
75+ # await tool_manager.register(tools, "calendar")
5076
77+
78+ @requires_keys ("TELEGRAM_API_ID" , "TELEGRAM_API_HASH" , "TELEGRAM_SESSION_STRING" )
5179async def load_telegram_tools (tool_manager ):
52- env = await get_telegram_config ()
80+ env = os .environ .copy ()
81+
82+ env ["TELEGRAM_API_ID" ] = os .environ ["TELEGRAM_API_ID" ]
83+ env ["TELEGRAM_API_HASH" ] = os .environ ["TELEGRAM_API_HASH" ]
84+ env ["TELEGRAM_SESSION_STRING" ] = os .environ ["TELEGRAM_SESSION_STRING" ]
5385
5486 telegram_client = MultiServerMCPClient ({
5587 "telegram" : {
@@ -70,9 +102,10 @@ async def load_telegram_tools(tool_manager):
70102 await tool_manager .register (filtered_tools , "telegram" )
71103
72104
105+ @requires_keys ("TAVILY_API_KEY" )
73106async def load_tavily_tools (tool_manager ):
74- env = await get_tavily_config ()
75- api_key = env ["TAVILY_API_KEY" ]
107+
108+ api_key = os . environ ["TAVILY_API_KEY" ]
76109
77110 tavily_client = MultiServerMCPClient ({
78111 "tavily" : {
@@ -84,9 +117,10 @@ async def load_tavily_tools(tool_manager):
84117 await tool_manager .register (tools , "tavily" )
85118
86119
120+ @requires_keys ("GITHUB_TOKEN" )
87121async def load_github_tools (tool_manager ):
88- env = await get_github_config ()
89- token = env ["GITHUB_TOKEN" ]
122+
123+ token = os . environ ["GITHUB_TOKEN" ]
90124
91125 github_client = MultiServerMCPClient ({
92126 "github" : {
@@ -99,13 +133,165 @@ async def load_github_tools(tool_manager):
99133 await tool_manager .register (tools , "github" )
100134
101135
136+ @requires_keys ("NOTION_TOKEN" )
137+ async def load_notion_tools (tool_manager ):
138+ env = os .environ .copy ()
139+
140+ # We already know this exists because the decorator checked!
141+ token = os .environ ["NOTION_TOKEN" ]
142+
143+ # Map it to whatever the MCP server expects (passing it as both just to be safe)
144+ env ["NOTION_TOKEN" ] = token
145+ env ["NOTION_API_KEY" ] = token
146+
147+ notion_client = MultiServerMCPClient ({
148+ "notion" : {
149+ "transport" : "stdio" ,
150+ "command" : "npx" ,
151+ "args" : ["-y" , "notion-mcp-server" ],
152+ "env" : env ,
153+ }
154+ })
155+
156+ tools = await notion_client .get_tools ()
157+ await tool_manager .register (tools , "notion" )
158+
159+
160+ @requires_keys ("SPOTIFY_CLIENT_ID" , "SPOTIFY_CLIENT_SECRET" , "SPOTIFY_REDIRECT_URI" )
161+ async def load_spotify_tools (tool_manager ):
162+ env = os .environ .copy ()
163+
164+ # Grab them directly; the decorator guarantees they are present
165+ env ["SPOTIFY_CLIENT_ID" ] = os .environ ["SPOTIFY_CLIENT_ID" ]
166+ env ["SPOTIFY_CLIENT_SECRET" ] = os .environ ["SPOTIFY_CLIENT_SECRET" ]
167+ env ["SPOTIFY_REDIRECT_URI" ] = os .environ .get ("SPOTIFY_REDIRECT_URI" , "http://127.0.0.1:8080/callback" )
168+
169+ spotify_client = MultiServerMCPClient ({
170+ "spotify" : {
171+ "transport" : "stdio" ,
172+ "command" : "uvx" ,
173+ "args" : [
174+ "--python" , "3.12" ,
175+ "--from" , "git+https://github.com/varunneal/spotify-mcp" ,
176+ "spotify-mcp" ,
177+ ],
178+ "env" : env ,
179+ }
180+ })
181+
182+ tools = await spotify_client .get_tools ()
183+ await tool_manager .register (tools , "spotify" )
184+
185+
186+ async def load_google_workspace_tools (tool_manager ):
187+ """
188+ Google Workspace MCP (aaronsb) - Gmail, Calendar, Drive, Docs, Sheets, Tasks, Meet
189+ Uses high-level tools (manage_email, manage_calendar, etc.)
190+ Auth is handled by the package itself via manage_accounts tool.
191+
192+ On first load, if no account is authenticated, automatically trigger the manage_accounts authenticate flow.
193+ """
194+
195+ client_id , client_secret = await get_google_config ()
196+
197+ workspace_client = MultiServerMCPClient ({
198+ "google-workspace" : {
199+ "transport" : "stdio" ,
200+ "command" : "npx" ,
201+ "args" : ["-y" , "@aaronsb/google-workspace-mcp" ],
202+ "env" : {
203+ "GOOGLE_CLIENT_ID" : client_id ,
204+ "GOOGLE_CLIENT_SECRET" : client_secret ,
205+ },
206+ }
207+ })
208+
209+ tools = await workspace_client .get_tools ()
210+ await tool_manager .register (tools , "google-workspace" )
211+
212+ # Auto-auth if no account is configured
213+ await auto_auth (tools )
214+
215+
216+ async def load_excalidraw_tools (tool_manager ):
217+ """
218+ Official Excalidraw MCP (remote) via mcp-remote bridge.
219+ Works with zero API keys for basic diagram creation.
220+ First connect may open a browser if OAuth is required.
221+ """
222+ excalidraw_client = MultiServerMCPClient ({
223+ "excalidraw" : {
224+ "transport" : "stdio" ,
225+ "command" : "npx" ,
226+ "args" : [
227+ "-y" ,
228+ "mcp-remote@latest" ,
229+ "https://mcp.excalidraw.com" ,
230+ ],
231+ }
232+ })
233+
234+ tools = await excalidraw_client .get_tools ()
235+ await tool_manager .register (tools , "excalidraw" )
236+
237+
238+ async def load_canva_tools (tool_manager ):
239+ """
240+ Official Canva remote MCP server.
241+ Uses mcp-remote so the OAuth browser flow works reliably.
242+ First connect will open a browser for you to authorize Canva.
243+ """
244+ canva_client = MultiServerMCPClient ({
245+ "canva" : {
246+ "transport" : "stdio" ,
247+ "command" : "npx" ,
248+ "args" : [
249+ "-y" ,
250+ "mcp-remote@latest" ,
251+ "https://mcp.canva.com/mcp" ,
252+ ],
253+ }
254+ })
255+
256+ tools = await canva_client .get_tools ()
257+ await tool_manager .register (tools , "canva" )
258+
259+
260+ async def load_linear_tools (tool_manager ):
261+ """
262+ Official Linear remote MCP server.
263+ Uses mcp-remote so the OAuth browser flow works reliably.
264+ First connect will open a browser for you to authorize Linear.
265+ """
266+ linear_client = MultiServerMCPClient ({
267+ "linear" : {
268+ "transport" : "stdio" ,
269+ "command" : "npx" ,
270+ "args" : [
271+ "-y" ,
272+ "mcp-remote@latest" ,
273+ "https://mcp.linear.app/mcp" ,
274+ ],
275+ }
276+ })
277+
278+ tools = await linear_client .get_tools ()
279+ await tool_manager .register (tools , "linear" )
280+
281+
102282# Registry of all available connectors — add new ones here
103283CONNECTORS = {
104- "swiggy" : load_swiggy_tools ,
105- "gmail" : load_gmail_tools ,
106- "telegram" : load_telegram_tools ,
107- "tavily" : load_tavily_tools ,
108- "github" : load_github_tools ,
284+ "swiggy" : load_swiggy_tools ,
285+ # "calendar": load_calendar_tools,
286+ "telegram" : load_telegram_tools ,
287+ "tavily" : load_tavily_tools ,
288+ "github" : load_github_tools ,
289+ "notion" : load_notion_tools ,
290+ "spotify" : load_spotify_tools ,
291+ "google_workspace" : load_google_workspace_tools ,
292+ "excalidraw" : load_excalidraw_tools ,
293+ "canva" : load_canva_tools ,
294+ "linear" : load_linear_tools ,
109295}
110296
111297# Some connectors register more than one MCP server under the hood
@@ -127,4 +313,80 @@ def get_connector_servers(name: str) -> list[str]:
127313
128314def is_connector_loaded (name : str , loaded_servers ) -> bool :
129315 """True if any server belonging to this connector is currently loaded."""
130- return any (server in loaded_servers for server in get_connector_servers (name ))
316+ return any (server in loaded_servers for server in get_connector_servers (name ))
317+
318+
319+ # ── Persistence: which connectors the user has turned on ────────────
320+ #
321+ # This does NOT persist the actual MCP tool objects/sessions (those are
322+ # short-lived, carry live tokens/clients and must be re-fetched fresh
323+ # every process start regardless). It only persists the *set of
324+ # connector names* the user has previously connected, so we know what
325+ # to reconnect to automatically on the next boot — instead of coming
326+ # up with zero tools and silently waiting for the user to notice and
327+ # re-run every /connect_* command by hand.
328+
329+ def _read_connected () -> set [str ]:
330+ if not CONNECTED_PATH .exists ():
331+ return set ()
332+ try :
333+ data = json .loads (CONNECTED_PATH .read_text ())
334+ return set (data .get ("connectors" , []))
335+ except Exception :
336+ log .warning ("connected_json_unreadable, treating as empty" )
337+ return set ()
338+
339+
340+ def _write_connected (names : set [str ]) -> None :
341+ CONNECTED_PATH .parent .mkdir (parents = True , exist_ok = True )
342+ CONNECTED_PATH .write_text (json .dumps ({"connectors" : sorted (names )}, indent = 2 ))
343+
344+
345+ def mark_connector_connected (name : str ) -> None :
346+ """Call this right after a connector's load_* function succeeds."""
347+ names = _read_connected ()
348+ if name not in names :
349+ names .add (name )
350+ _write_connected (names )
351+ log .info ("connector_marked_persisted" , connector = name )
352+
353+
354+ def mark_connector_disconnected (name : str ) -> None :
355+ """Call this from /disconnect_* so we don't try to reconnect it on next boot."""
356+ names = _read_connected ()
357+ if name in names :
358+ names .discard (name )
359+ _write_connected (names )
360+ log .info ("connector_unmarked_persisted" , connector = name )
361+
362+
363+ async def restore_connected_connectors (tool_manager ) -> None :
364+ """
365+ Call this once at startup (after tool_manager exists, before/alongside
366+ initialize_agent). Reconnects every connector the user had previously
367+ turned on, using the SAME load_* functions /connect_* commands use —
368+ so auth/token fetching happens fresh, only the "which ones" list is
369+ persisted.
370+
371+ Best-effort per connector: one connector failing to reconnect (e.g.
372+ expired token, MCP server down) must not block the others or crash
373+ startup — that would turn "some data reset" into "nothing works".
374+ """
375+ names = _read_connected ()
376+ if not names :
377+ log .info ("no_persisted_connectors_to_restore" )
378+ return
379+
380+ for name in sorted (names ):
381+ loader = CONNECTORS .get (name )
382+ if loader is None :
383+ log .warning ("persisted_connector_unknown_skipping" , connector = name )
384+ continue
385+ try :
386+ await loader (tool_manager )
387+ log .info ("connector_restored" , connector = name )
388+ except Exception as e :
389+ log .warning ("connector_restore_failed" , connector = name , error = str (e ))
390+ # Leave it marked as "connected" in connected.json — it was a
391+ # transient failure (bad token, MCP server down), not the user
392+ # disconnecting it. We'll just retry on the next restart.
0 commit comments