Skip to content

Commit cbbd730

Browse files
Merge pull request #94 from srijanAtGithub/dev
release v3.0.0 — MCP servers, model upgrades, cost improvements, better tool retrieval and Navigator enhancements
2 parents 7da3d28 + 4f21d24 commit cbbd730

51 files changed

Lines changed: 3672 additions & 763 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ build/
77
dist/
88
wheels/
99
*.egg-info
10+
.cache
1011

1112
# Virtual environments
1213
.venv

Agent/agent.py

Lines changed: 190 additions & 64 deletions
Large diffs are not rendered by default.

Agent/connectors.py

Lines changed: 288 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,31 @@
1+
import os
2+
import json
3+
from pathlib import Path
4+
15
from langchain_mcp_adapters.client import MultiServerMCPClient
26
from 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

89
from 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+
1029
async 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")
5179
async 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")
73106
async 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")
87121
async 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
103283
CONNECTORS = {
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

128314
def 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

Comments
 (0)