splitwise-mcp 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
File without changes
@@ -0,0 +1,105 @@
1
+ import sounddevice as sd
2
+ import numpy as np
3
+ import scipy.io.wavfile as wav
4
+ import tempfile
5
+ import os
6
+ from deepgram import DeepgramClient
7
+
8
+ class AudioTranscriber:
9
+ def __init__(self):
10
+ # Initialize Deepgram client
11
+ api_key = os.getenv("DEEPGRAM_API_KEY")
12
+ if not api_key:
13
+ raise ValueError("Missing DEEPGRAM_API_KEY in .env")
14
+ self.client = DeepgramClient(api_key=api_key)
15
+
16
+ def record_audio(self, duration=10, sample_rate=44100):
17
+ """
18
+ Record audio from the microphone for a fixed duration.
19
+ Returns the path to the temporary WAV file.
20
+ """
21
+ print(f"🎤 Recording for {duration} seconds... (Speak now!)")
22
+
23
+ recording = sd.rec(int(duration * sample_rate), samplerate=sample_rate, channels=1)
24
+ sd.wait() # Wait until recording is finished
25
+
26
+ print("✅ Recording finished.")
27
+
28
+ # Save to temp file
29
+ with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as temp_audio:
30
+ wav.write(temp_audio.name, sample_rate, recording)
31
+ return temp_audio.name
32
+
33
+ def transcribe_bytes(self, buffer_data):
34
+ """
35
+ Transcribes audio bytes directly.
36
+ """
37
+ print("📝 Transcribing bytes with Deepgram...")
38
+
39
+ # v5.x: Pass bytes as 'request' kwarg, and options as kwargs
40
+ response = self.client.listen.v1.media.transcribe_file(
41
+ request=buffer_data,
42
+ model="nova-2",
43
+ smart_format=True,
44
+ language="en"
45
+ )
46
+
47
+ transcript = response.results.channels[0].alternatives[0].transcript
48
+ return transcript
49
+
50
+ def generate_speech(self, text):
51
+ """
52
+ Generates speech from text using Deepgram Aura (TTS).
53
+ Returns raw audio bytes (mp3).
54
+ """
55
+ print(f"🗣️ Generating speech for: {text[:50]}...")
56
+ # Deepgram TTS (Aura)
57
+ SPEAK_OPTIONS = {"text": text}
58
+ # Model: aura-asteria-en (Female) or aura-orion-en (Male)
59
+ # Using Asteria for a friendly assistant voice
60
+ options = {
61
+ "model": "aura-asteria-en",
62
+ "encoding": "mp3",
63
+ }
64
+
65
+ # Save to a temporary file is standard, but keeping in memory is better for Streamlit
66
+ # Deepgram SDK .save method saves to file.
67
+ # We can use .stream? Or just save to temp and read back.
68
+
69
+ with tempfile.NamedTemporaryFile(suffix=".mp3", delete=False) as temp_tts:
70
+ filename = temp_tts.name
71
+
72
+ # self.client.speak.v("1").save... failed (attribute error).
73
+ # We found self.client.speak.v1.audio.generate exists.
74
+
75
+ response = self.client.speak.v1.audio.generate(SPEAK_OPTIONS, options)
76
+
77
+ # Check if response handles saving (SDK wrapper) or if we need to write bytes
78
+ if hasattr(response, "save"):
79
+ response.save(filename)
80
+ elif hasattr(response, "content"):
81
+ with open(filename, "wb") as f:
82
+ f.write(response.content)
83
+ else:
84
+ # Assume it is bytes directly
85
+ with open(filename, "wb") as f:
86
+ f.write(response)
87
+
88
+ with open(filename, "rb") as f:
89
+ audio_bytes = f.read()
90
+
91
+ os.remove(filename) # Clean up
92
+ return audio_bytes
93
+
94
+ def transcribe(self, audio_path):
95
+ """
96
+ Transcribes the audio file using Deepgram.
97
+ """
98
+ print("📝 Transcribing with Deepgram...")
99
+ with open(audio_path, "rb") as audio:
100
+ buffer_data = audio.read()
101
+ return self.transcribe_bytes(buffer_data)
102
+
103
+ def cleanup(self, audio_path):
104
+ if os.path.exists(audio_path):
105
+ os.remove(audio_path)
@@ -0,0 +1,281 @@
1
+ import os
2
+ import json
3
+ from google import genai
4
+ from google.genai import types
5
+ from splitwise_mcp.client import SplitwiseClient
6
+ from colorama import Fore, Style
7
+
8
+ class GeminiSplitwiseAgent:
9
+ def __init__(self):
10
+ api_key = os.getenv("GEMINI_API_KEY")
11
+ if not api_key:
12
+ raise ValueError("Missing GEMINI_API_KEY in .env")
13
+
14
+ self.client = genai.Client(api_key=api_key)
15
+ self.splitwise = SplitwiseClient()
16
+ self.model_name = "gemini-3-flash-preview"
17
+
18
+ # Tools definitions
19
+ self.tool_functions = {
20
+ "add_expense": self._add_expense_impl,
21
+ "list_friends": self._list_friends_impl,
22
+ "delete_expense": self._delete_expense_impl,
23
+ "_add_expense_impl": self._add_expense_impl,
24
+ "_list_friends_impl": self._list_friends_impl,
25
+ "_delete_expense_impl": self._delete_expense_impl
26
+ }
27
+
28
+ # Pre-load friends and groups for context
29
+ print(f"{Fore.CYAN}👥 Pre-loading friends and groups...{Style.RESET_ALL}")
30
+ try:
31
+ friends = self.splitwise.get_friends()
32
+ self.friend_list_str = ", ".join([f"{f.getFirstName()} {f.getLastName()} (ID: {f.getId()})" for f in friends])
33
+
34
+ groups = self.splitwise.get_groups()
35
+ self.group_list_str = ", ".join([f"{g.getName()} (ID: {g.getId()})" for g in groups])
36
+ except Exception as e:
37
+ print(f"{Fore.RED}⚠️ Failed to pre-load data: {e}{Style.RESET_ALL}")
38
+ self.friend_list_str = "Could not load friends."
39
+ self.group_list_str = "Could not load groups."
40
+
41
+ system_prompt = (
42
+ "You are a helpful assistant that manages Splitwise expenses.\n"
43
+ f"Here is the user's current friend list: [{self.friend_list_str}].\n"
44
+ f"Here is the user's current group list: [{self.group_list_str}].\n"
45
+ "Rules:\n"
46
+ "1. If the transcribed name does NOT exactly match a friend's name in the list, "
47
+ "you MUST ask for clarification. For example, if user says 'Humeet' but you have 'Sumeet' in the list, "
48
+ "ask 'Did you mean Sumeet?' Do NOT assume phonetic matches.\n"
49
+ "2. If the name is completely not found, ask the user to spell it again.\n"
50
+ "3. Do NOT guess friend IDs. Only use IDs from the list above.\n"
51
+ "4. If the user wants to add an expense, call 'add_expense' with the matched friend names.\n"
52
+ "5. If the user specifies unequal splits (e.g. 'I owe 10, Sumeet owes 20', or 'Split 50/50%'), use 'split_map'. "
53
+ "Map 'me' or 'I' to the user's share, and friend names to their share (amounts or percentages).\n"
54
+ "6. If the user mentions a group (e.g. 'add to Apartment group'), use 'group_name'. Match against the group list above.\n"
55
+ "7. If the user specifies who paid (e.g. 'Alice paid'), use 'payer_name'. Default is YOU paid.\n"
56
+ "8. If excluding someone from a group expense, use 'exclude_names'.\n"
57
+ "9. To delete an expense, use 'delete_expense' with the ID (if known) or ask user for it.\n"
58
+ "10. Be concise and conversational."
59
+ )
60
+
61
+ # Create chat session
62
+ self.chat = self.client.chats.create(
63
+ model=self.model_name,
64
+ config=types.GenerateContentConfig(
65
+ tools=[self._add_expense_impl, self._list_friends_impl, self._delete_expense_impl],
66
+ system_instruction=system_prompt,
67
+ automatic_function_calling={"disable": True}
68
+ )
69
+ )
70
+
71
+ # --- Tool Implementations ---
72
+ # --- Tool Implementations ---
73
+ def _add_expense_impl(self, amount: str, description: str, friend_names: list[str], split_map: dict = None, group_name: str = None, payer_name: str = None, exclude_names: list[str] = None):
74
+ """Add a new expense to Splitwise. Use this when the user wants to split a cost.
75
+
76
+ Args:
77
+ amount: The numeric amount of the expense (e.g. '50.00').
78
+ description: Short description of the expense (e.g. 'Dinner', 'Cab').
79
+ friend_names: List of names of friends to split with. Can be empty if matching a group.
80
+ split_map: Optional dictionary for unequal splits.
81
+ Keys are names (use 'me' for yourself), Values are amounts (e.g. '10.50') OR percentages (e.g. '50%').
82
+ Example: {'me': '40%', 'Sumeet Singh': '60%'}
83
+ group_name: Optional name of the group to add this expense to.
84
+ payer_name: Optional name of who paid the full amount. Defaults to current user if not specified.
85
+ exclude_names: Optional list of names to exclude from a group split.
86
+ """
87
+ # This function won't be called automatically by Gemini anymore.
88
+ # We will call it manually in 'execute_tool'.
89
+ print(f"{Fore.YELLOW}🛠️ Executing: add_expense({amount}, {description}, {friend_names}, split_map={split_map}, group_name={group_name}, payer={payer_name}, exclude={exclude_names}){Style.RESET_ALL}")
90
+ try:
91
+ res = self.splitwise.add_expense(amount, description, friend_names, split_map=split_map, group_name=group_name, payer_name=payer_name, exclude_names=exclude_names)
92
+ if res:
93
+ return f"Success! Added expense (ID: {res.getId()})"
94
+ return "Failed to add expense."
95
+ except Exception as e:
96
+ return f"Error: {e}"
97
+
98
+ def _delete_expense_impl(self, expense_id: str):
99
+ """Delete an expense by ID."""
100
+ print(f"{Fore.YELLOW}🛠️ Executing: delete_expense({expense_id}){Style.RESET_ALL}")
101
+ try:
102
+ self.splitwise.delete_expense(expense_id)
103
+ return f"Success! Deleted expense {expense_id}."
104
+ except Exception as e:
105
+ return f"Error: {e}"
106
+
107
+ def _list_friends_impl(self):
108
+ """List the user's friends on Splitwise."""
109
+ print(f"{Fore.YELLOW}🛠️ Executing: list_friends(){Style.RESET_ALL}")
110
+ try:
111
+ friends = self.splitwise.get_friends()
112
+ names = [f"{f.getFirstName()} {f.getLastName()}" for f in friends]
113
+ return f"Friends: {', '.join(names)}"
114
+ except Exception as e:
115
+ return f"Error: {e}"
116
+
117
+ # --- Agent Logic ---
118
+
119
+ def process_input(self, user_text: str):
120
+ """
121
+ Sends text to Gemini.
122
+ Returns structure:
123
+ { "type": "text", "content": "..." }
124
+ OR
125
+ { "type": "confirmation_required", "tool_name": "...", "tool_args": {...}, "call_id": ... }
126
+ """
127
+ print(f"{Fore.CYAN}🧠 Thinking...{Style.RESET_ALL}")
128
+ response = self.chat.send_message(user_text)
129
+
130
+ # Check if the model wants to call a function
131
+ # response.parts is a list. Look for function_call.
132
+ if response.candidates and response.candidates[0].content.parts:
133
+ for part in response.candidates[0].content.parts:
134
+ if part.function_call:
135
+ fc = part.function_call
136
+ tool_name = fc.name
137
+ tool_args = fc.args
138
+
139
+ # Optimization: Auto-execute read-only tools
140
+ if tool_name == "_list_friends_impl":
141
+ print(f"{Fore.MAGENTA}🔄 Auto-executing read-only tool: {tool_name}{Style.RESET_ALL}")
142
+ # Execute and feed back to model
143
+ # 1. Execute tool locally.
144
+ func = self.tool_functions.get(tool_name)
145
+ if not func:
146
+ res_str = f"Error: Tool {tool_name} not found."
147
+ else:
148
+ res_str = func(**tool_args)
149
+
150
+ # 2. Send ToolResponse to Gemini
151
+ tool_response_part = types.Part(
152
+ function_response=types.FunctionResponse(
153
+ name=tool_name,
154
+ response={'result': res_str}
155
+ )
156
+ )
157
+ print(f"{Fore.MAGENTA}📤 Sending auto-result back to model...{Style.RESET_ALL}")
158
+
159
+ # Recurse: Ask model again
160
+ next_response = self.chat.send_message([tool_response_part])
161
+
162
+ # Check THIS response for function calls (e.g. add_expense)
163
+ if next_response.candidates and next_response.candidates[0].content.parts:
164
+ for next_part in next_response.candidates[0].content.parts:
165
+ if next_part.function_call:
166
+ return {
167
+ "type": "confirmation_required",
168
+ "tool_name": next_part.function_call.name,
169
+ "tool_args": next_part.function_call.args,
170
+ "call_id": "auto_recursive"
171
+ }
172
+
173
+ # Else it's text
174
+ return {
175
+ "type": "text",
176
+ "content": next_response.text
177
+ }
178
+
179
+
180
+
181
+ return {
182
+ "type": "confirmation_required",
183
+ "tool_name": tool_name,
184
+ "tool_args": tool_args, # Dictionary
185
+ "call_id": "manual_execution"
186
+ }
187
+
188
+ # Otherwise just text
189
+ return {
190
+ "type": "text",
191
+ "content": response.text
192
+ }
193
+
194
+ def execute_tool_and_reply(self, tool_name, tool_args):
195
+ """
196
+ Executes the tool (after user said YES) and sends the output back to Gemini.
197
+ Returns the final text response from Gemini.
198
+ """
199
+ # 1. Execute
200
+ func = self.tool_functions.get(tool_name)
201
+ if not func:
202
+ result = f"Error: Tool {tool_name} not found."
203
+ else:
204
+ try:
205
+ # Unpack args
206
+ result = func(**tool_args)
207
+ except Exception as e:
208
+ result = f"Error calling function: {e}"
209
+
210
+ print(f"{Fore.GREEN}✅ Output: {result}{Style.RESET_ALL}")
211
+
212
+ # 2. Send result back to Gemini
213
+ # We need to construct a ToolResponse part.
214
+
215
+ # In the new SDK, we send the message with the function response.
216
+ # We need to construct the 'parts' with 'function_response'.
217
+
218
+ # NOTE: With automatic_function_calling disabled, we need to send the response manually.
219
+ # The SDK expects us to send a message containing the function response.
220
+
221
+ # part = types.Part.from_function_response(name=tool_name, response={'result': result})
222
+ # API expects specific structure.
223
+
224
+ # Let's try sending it as a properly formatted tool response.
225
+ tool_response_part = types.Part(
226
+ function_response=types.FunctionResponse(
227
+ name=tool_name,
228
+ response={'result': result}
229
+ )
230
+ )
231
+
232
+ try:
233
+ response = self.chat.send_message([tool_response_part])
234
+ return response.text
235
+ except Exception as e:
236
+ print(f"{Fore.RED}⚠️ Gemini failed to acknowledge tool execution: {e}{Style.RESET_ALL}")
237
+ return f"✅ Action Executed Successfully: {result}\n\n(Note: Gemini could not generate a follow-up reply due to network/server issues)."
238
+
239
+ def reject_tool(self, reason: str):
240
+ """
241
+ User said NO or provided correction.
242
+ We send this feedback to Gemini so it can try again.
243
+ """
244
+ # We just send the user's correction text.
245
+ # Gemini sees:
246
+ # User: "Split 50..."
247
+ # Model: Call add_expense(50)
248
+ # User (Feedback): "No, change amount to 15"
249
+ # Model: Call add_expense(15)
250
+
251
+ # BUT, if we just send "No..." does Gemini know we REJECTED the previous call?
252
+ # Since we didn't send the ToolResponse, the previous turn is technically incomplete?
253
+ # Actually in Google GenAI, if we don't send the function response,
254
+ # we might need to "rewind" or just send the text.
255
+ # Sending text "No, do X" usually works as a follow up.
256
+
257
+ # Let's treat it as a new user message.
258
+ return self.process_input(reason)
259
+
260
+ def process_and_execute(self, user_text: str) -> str:
261
+ """
262
+ Process user text and auto-execute any tool calls.
263
+ Used by MCP server where tools should be executed without human confirmation.
264
+
265
+ Returns the final response text (either from Gemini or tool execution result).
266
+ """
267
+ result = self.process_input(user_text)
268
+
269
+ if result["type"] == "text":
270
+ return result["content"]
271
+
272
+ elif result["type"] == "confirmation_required":
273
+ # Auto-execute the tool
274
+ tool_name = result["tool_name"]
275
+ tool_args = result["tool_args"]
276
+ return self.execute_tool_and_reply(tool_name, tool_args)
277
+
278
+ return "Unexpected response type."
279
+
280
+ # Export
281
+ SplitwiseAgent = GeminiSplitwiseAgent
@@ -0,0 +1,285 @@
1
+ import os
2
+ from dotenv import load_dotenv
3
+ from splitwise import Splitwise
4
+ from splitwise.expense import Expense
5
+ from splitwise.user import ExpenseUser
6
+ from typing import List, Optional
7
+
8
+ load_dotenv()
9
+
10
+ class SplitwiseClient:
11
+ def __init__(self):
12
+ self.consumer_key = os.getenv("SPLITWISE_CONSUMER_KEY")
13
+ self.consumer_secret = os.getenv("SPLITWISE_CONSUMER_SECRET")
14
+ self.api_key = os.getenv("SPLITWISE_API_KEY")
15
+ self.access_token = None
16
+
17
+ self.client = None
18
+ self._current_user = None
19
+
20
+ # Try to initialize if env vars are present
21
+ if (self.consumer_key and self.consumer_secret) or self.api_key:
22
+ self._init_client()
23
+
24
+ def _init_client(self):
25
+ self.client = Splitwise(
26
+ consumer_key=self.consumer_key,
27
+ consumer_secret=self.consumer_secret,
28
+ api_key=self.api_key
29
+ )
30
+ # If we have an access token, set it.
31
+ # Note: The library might expect a dict for OAuth1, or handle OAuth2 differently.
32
+ # Assuming we can just populate the internal state or user uses 'api_key' as bearer.
33
+ if self.access_token:
34
+ # We assume dict format for stability with common library versions
35
+ # But for purely OAuth2, it might differ.
36
+ # We'll set it as a dictionary which is a common pattern for this lib.
37
+ self.client.setAccessToken({'oauth_token': self.access_token, 'oauth_token_secret': ''})
38
+
39
+ def configure(self, consumer_key: str = None, consumer_secret: str = None, api_key: str = None, access_token: str = None):
40
+ """
41
+ Configure the client with credentials at runtime.
42
+ """
43
+ if consumer_key: self.consumer_key = consumer_key
44
+ if consumer_secret: self.consumer_secret = consumer_secret
45
+ if api_key: self.api_key = api_key
46
+ if access_token: self.access_token = access_token
47
+
48
+ self._init_client()
49
+
50
+ self._current_user = None
51
+
52
+ def get_current_user(self):
53
+ if not self.client:
54
+ raise ValueError("Splitwise client not configured. Please use 'configure_splitwise' tool.")
55
+
56
+ if not self._current_user:
57
+ self._current_user = self.client.getCurrentUser()
58
+ return self._current_user
59
+
60
+ def get_friends(self):
61
+ if not self.client:
62
+ raise ValueError("Splitwise client not configured. Please use 'configure_splitwise' tool.")
63
+ return self.client.getFriends()
64
+
65
+ def find_friend_by_name(self, name: str):
66
+ friends = self.get_friends()
67
+ name_lower = name.lower()
68
+ for friend in friends:
69
+ # Check first, last, and full name
70
+ first = (friend.getFirstName() or "").lower()
71
+ last = (friend.getLastName() or "").lower()
72
+ full = f"{first} {last}".strip()
73
+
74
+ if name_lower in full or name_lower == first or name_lower == last:
75
+ return friend
76
+ return None
77
+
78
+ def get_groups(self):
79
+ if not self.client:
80
+ raise ValueError("Splitwise client not configured. Please use 'configure_splitwise' tool.")
81
+ return self.client.getGroups()
82
+
83
+ def find_group_by_name(self, name: str):
84
+ groups = self.get_groups()
85
+ name_lower = name.lower()
86
+ for group in groups:
87
+ if group.getName().lower() == name_lower:
88
+ return group
89
+ return None
90
+
91
+ def add_expense(self, amount: str, description: str, friend_names: List[str], split_map: dict = None, group_name: str = None, payer_name: str = None, exclude_names: List[str] = None):
92
+ """
93
+ Splits an expense.
94
+ If split_map is None, splits equally.
95
+ If split_map is provided:
96
+ - Keys are names (use "me" or "I" for current user).
97
+ - Values are amounts (e.g. "10.00") OR percentages (e.g. "50%").
98
+ If group_name is provided:
99
+ - If friend_names is empty, fetches all group members.
100
+ - Adds expense to that group.
101
+ If payer_name is provided:
102
+ - Specifies who paid the full amount. Defaults to current user.
103
+ If exclude_names is provided:
104
+ - Remixes group members to exclude these names.
105
+ """
106
+ current_user = self.get_current_user()
107
+ users_in_split = []
108
+
109
+ # 1. Resolve Group & Members
110
+ group_id = None
111
+ if group_name:
112
+ group = self.find_group_by_name(group_name)
113
+ if not group:
114
+ raise ValueError(f"Group not found: {group_name}")
115
+ group_id = group.getId()
116
+
117
+ # Auto-fetch members if friend_names is empty
118
+ if not friend_names:
119
+ members = group.getMembers()
120
+ # Filter out excluded members
121
+ if exclude_names:
122
+ # Normalize exclude names
123
+ excludes_lower = [n.lower() for n in exclude_names]
124
+ members = [
125
+ m for m in members
126
+ if f"{m.getFirstName()} {m.getLastName()}".strip().lower() not in excludes_lower
127
+ and m.getFirstName().lower() not in excludes_lower
128
+ ]
129
+ users_in_split = members
130
+
131
+ # 2. Resolve Friends (if not using group auto-fetch)
132
+ if not users_in_split:
133
+ users_in_split = [current_user]
134
+ friend_objects = {}
135
+ for name in friend_names:
136
+ friend = self.find_friend_by_name(name)
137
+ if not friend:
138
+ raise ValueError(f"Friend not found: {name}")
139
+ users_in_split.append(friend)
140
+
141
+ # Key by full name for split_map matching
142
+ full_name = f"{friend.getFirstName()} {friend.getLastName()}".strip()
143
+ friend_objects[full_name] = friend
144
+ # Also key by first name? Ideally agent canonicalizes names.
145
+
146
+ # Deduplicate based on ID just in case
147
+ unique_users = {}
148
+ for u in users_in_split:
149
+ unique_users[u.getId()] = u
150
+ users_in_split = list(unique_users.values())
151
+
152
+ total_amount = float(amount)
153
+
154
+ # 3. Create expense users
155
+ expense_users = []
156
+
157
+ # Resolve Payer
158
+ payer_id = current_user.getId()
159
+ if payer_name and payer_name.lower() not in ["me", "i", "myself"]:
160
+ # Find payer in our list or friend list
161
+ payer_found = False
162
+ # Search in split users first
163
+ for u in users_in_split:
164
+ f_name = f"{u.getFirstName()} {u.getLastName()}".strip()
165
+ if payer_name.lower() in f_name.lower() or payer_name.lower() == u.getFirstName().lower():
166
+ payer_id = u.getId()
167
+ payer_found = True
168
+ break
169
+
170
+ if not payer_found:
171
+ # Try finding explicitly if not in split (e.g. payer paid but is not part of split?)
172
+ p = self.find_friend_by_name(payer_name)
173
+ if p:
174
+ payer_id = p.getId()
175
+ # If payer is not in split, add them to users (paid share set later)
176
+ if p.getId() not in unique_users:
177
+ users_in_split.append(p)
178
+ unique_users[p.getId()] = p
179
+ else:
180
+ raise ValueError(f"Payer not found: {payer_name}")
181
+
182
+ if split_map:
183
+ # Unequal split logic
184
+ total_split = 0.0
185
+
186
+ for user in users_in_split:
187
+ eu = ExpenseUser()
188
+ eu.setId(user.getId())
189
+
190
+ # Paid Share
191
+ if user.getId() == payer_id:
192
+ eu.setPaidShare(f"{total_amount:.2f}")
193
+ else:
194
+ eu.setPaidShare("0.00")
195
+
196
+ # Owed Share
197
+ owed = 0.0
198
+
199
+ # Match user in split_map
200
+ key_to_use = None
201
+
202
+ # Check "me"
203
+ if user.getId() == current_user.getId():
204
+ if "me" in split_map: key_to_use = "me"
205
+ elif "Me" in split_map: key_to_use = "Me"
206
+ elif "I" in split_map: key_to_use = "I"
207
+
208
+ # Check Name
209
+ if not key_to_use:
210
+ f_name = f"{user.getFirstName()} {user.getLastName()}".strip()
211
+ if f_name in split_map: key_to_use = f_name
212
+ else:
213
+ for k in split_map:
214
+ if k.lower() in f_name.lower():
215
+ key_to_use = k
216
+ break
217
+
218
+ if key_to_use:
219
+ val = split_map[key_to_use]
220
+ # Percentage Check
221
+ if isinstance(val, str) and val.strip().endswith("%"):
222
+ pct = float(val.strip().replace("%", ""))
223
+ owed = (pct / 100.0) * total_amount
224
+ else:
225
+ owed = float(val)
226
+
227
+ eu.setOwedShare(f"{owed:.2f}")
228
+ total_split += owed
229
+ expense_users.append(eu)
230
+
231
+ # Validation (loose due to float math)
232
+ if abs(total_split - total_amount) > 0.05:
233
+ pass
234
+
235
+ else:
236
+ # Equal split logic
237
+ num_users = len(users_in_split)
238
+ share = total_amount / num_users
239
+ for user in users_in_split:
240
+ eu = ExpenseUser()
241
+ eu.setId(user.getId())
242
+
243
+ if user.getId() == payer_id:
244
+ eu.setPaidShare(f"{total_amount:.2f}")
245
+ else:
246
+ eu.setPaidShare("0.00")
247
+
248
+ eu.setOwedShare(f"{share:.2f}")
249
+ expense_users.append(eu)
250
+
251
+ expense = Expense()
252
+ expense.setCost(f"{total_amount:.2f}")
253
+ expense.setDescription(description)
254
+ expense.setUsers(expense_users)
255
+
256
+ if group_id:
257
+ expense.setGroupId(group_id)
258
+
259
+ # Handling potential 0.01 rounding errors?
260
+ # API might reject if sums don't match exactly.
261
+ # Simple fix: the payload requires string.
262
+ # splitwise library handles some of this?
263
+ # Let's hope basic division works for now.
264
+ # Advanced: distrubute remainder.
265
+
266
+ expense, errors = self.client.createExpense(expense)
267
+
268
+ if errors:
269
+ raise Exception(f"Splitwise Error: {errors.getErrors()}")
270
+
271
+ return expense
272
+
273
+ def delete_expense(self, expense_id: str):
274
+ """
275
+ Delete an expense by ID.
276
+ """
277
+ if not self.client:
278
+ raise ValueError("Splitwise client not configured.")
279
+
280
+ success, errors = self.client.deleteExpense(expense_id)
281
+ if success:
282
+ return True
283
+ else:
284
+ raise Exception(f"Failed to delete expense: {errors.getErrors()}")
285
+
@@ -0,0 +1,217 @@
1
+ from mcp.server.fastmcp import FastMCP
2
+ from splitwise_mcp.client import SplitwiseClient
3
+ import logging
4
+ import base64
5
+
6
+ # Initialize FastMCP
7
+ mcp = FastMCP("splitwise")
8
+
9
+ # Global client (for direct Splitwise tools)
10
+ client = SplitwiseClient()
11
+
12
+ # Lazy-initialized agent (for voice/text command tools)
13
+ _agent = None
14
+ _transcriber = None
15
+
16
+ def _get_agent():
17
+ """Lazy-initialize the Gemini agent."""
18
+ global _agent
19
+ if _agent is None:
20
+ from splitwise_mcp.agent.client import GeminiSplitwiseAgent
21
+ _agent = GeminiSplitwiseAgent()
22
+ return _agent
23
+
24
+ def _get_transcriber():
25
+ """Lazy-initialize the Deepgram transcriber."""
26
+ global _transcriber
27
+ if _transcriber is None:
28
+ from splitwise_mcp.agent.audio import AudioTranscriber
29
+ _transcriber = AudioTranscriber()
30
+ return _transcriber
31
+
32
+ # =============================================================================
33
+ # Voice Agent Tools (Full Pipeline)
34
+ # =============================================================================
35
+
36
+ @mcp.tool()
37
+ def voice_command(audio_base64: str) -> str:
38
+ """
39
+ Process a voice command for Splitwise.
40
+
41
+ Accepts base64-encoded audio (WAV or MP3 format), transcribes it using Deepgram,
42
+ processes the intent using Gemini, and executes Splitwise actions.
43
+
44
+ Args:
45
+ audio_base64: Base64-encoded audio data (WAV or MP3).
46
+
47
+ Returns:
48
+ The result of the voice command (e.g., confirmation, clarification request, or error).
49
+ """
50
+ try:
51
+ # Decode audio
52
+ audio_bytes = base64.b64decode(audio_base64)
53
+
54
+ # Transcribe with Deepgram
55
+ transcriber = _get_transcriber()
56
+ transcript = transcriber.transcribe_bytes(audio_bytes)
57
+
58
+ if not transcript or not transcript.strip():
59
+ return "Could not transcribe audio. Please try again with clearer audio."
60
+
61
+ # Process with Gemini agent
62
+ agent = _get_agent()
63
+ result = agent.process_and_execute(transcript)
64
+
65
+ return f"Transcribed: \"{transcript}\"\n\nResult: {result}"
66
+
67
+ except Exception as e:
68
+ return f"Voice command error: {e}"
69
+
70
+ @mcp.tool()
71
+ def text_command(text: str) -> str:
72
+ """
73
+ Process a text command for Splitwise.
74
+
75
+ Interprets natural language text using Gemini and executes Splitwise actions.
76
+ Use this when you already have text (e.g., from a chat message) instead of audio.
77
+
78
+ Args:
79
+ text: Natural language command (e.g., "Split 50 with Sumeet for dinner").
80
+
81
+ Returns:
82
+ The result of the command (e.g., confirmation, clarification request, or error).
83
+ """
84
+ try:
85
+ agent = _get_agent()
86
+ result = agent.process_and_execute(text)
87
+ return result
88
+ except Exception as e:
89
+ return f"Text command error: {e}"
90
+
91
+ # =============================================================================
92
+ # Direct Splitwise Tools (For Manual Control)
93
+ # =============================================================================
94
+
95
+ @mcp.tool()
96
+ def configure_splitwise(consumer_key: str = None, consumer_secret: str = None, api_key: str = None) -> str:
97
+ """
98
+ Configure the Splitwise client with API credentials.
99
+ You must provide either (consumer_key AND consumer_secret) OR api_key.
100
+ """
101
+ try:
102
+ client.configure(consumer_key, consumer_secret, api_key)
103
+ # Verify it works by getting current user
104
+ user = client.get_current_user()
105
+ name = f"{user.getFirstName()} {user.getLastName()}".strip()
106
+ return f"Successfully configured Splitwise for user: {name}"
107
+ except Exception as e:
108
+ return f"Configuration failed: {e}. Please check your keys."
109
+
110
+ @mcp.tool()
111
+ def login_with_token(access_token: str) -> str:
112
+ """
113
+ Log in using an existing OAuth2 Access Token.
114
+ Useful for integrations where authentication is handled externally (e.g. ChatGPT).
115
+ """
116
+ try:
117
+ client.configure(access_token=access_token)
118
+ # Verify
119
+ user = client.get_current_user()
120
+ name = f"{user.getFirstName()} {user.getLastName()}".strip()
121
+ return f"Successfully logged in as: {name}"
122
+ except Exception as e:
123
+ return f"Login failed: {e}. Token might be invalid."
124
+
125
+
126
+ @mcp.tool()
127
+ def list_friends() -> str:
128
+ """
129
+ List all friends of the current user on Splitwise.
130
+ Returns a formatted string list of friends.
131
+ """
132
+ try:
133
+ # Client check is handled inside client.get_friends()
134
+ friends = client.get_friends()
135
+
136
+ if not friends:
137
+ return "No friends found."
138
+
139
+ output = ["Current Friends:"]
140
+ for f in friends:
141
+ name = f"{f.getFirstName() or ''} {f.getLastName() or ''}".strip()
142
+ output.append(f"- {name} (ID: {f.getId()})")
143
+
144
+ return "\n".join(output)
145
+
146
+ except ValueError as e:
147
+ return f"Configuration Error: {e}"
148
+ except Exception as e:
149
+ return f"Error listing friends: {e}"
150
+
151
+ @mcp.tool()
152
+ def add_expense(
153
+ amount: str,
154
+ description: str,
155
+ friend_names: list[str],
156
+ split_map: dict = None,
157
+ group_name: str = None,
158
+ payer_name: str = None,
159
+ exclude_names: list[str] = None
160
+ ) -> str:
161
+ """
162
+ Add an expense to Splitwise, supporting unequal splits, groups, and precise control.
163
+
164
+ Args:
165
+ amount: The total cost (e.g., "70", "10.50").
166
+ description: A brief description.
167
+ friend_names: Friends to split with. Can be empty if using `group_name`.
168
+ split_map: Optional dict for unequal splits. Keys=Names (or 'me'), Values=Amount/Percentage.
169
+ Example: {'me': '40%', 'Alice': '60%'} or {'me': '10', 'Bob': '20'}
170
+ group_name: Optional group to add expense to.
171
+ payer_name: Optional name of who paid. Defaults to 'me'.
172
+ exclude_names: Optional list of names to exclude from a group split.
173
+ """
174
+ if not client.client:
175
+ return "Error: Splitwise client not configured. Use 'configure_splitwise' first."
176
+
177
+ try:
178
+ expense = client.add_expense(
179
+ amount,
180
+ description,
181
+ friend_names,
182
+ split_map=split_map,
183
+ group_name=group_name,
184
+ payer_name=payer_name,
185
+ exclude_names=exclude_names
186
+ )
187
+ if expense:
188
+ return f"Successfully added expense '{description}' for {amount}. (ID: {expense.getId()})"
189
+ else:
190
+ errors = client.client.getErrors()
191
+ return f"Failed to add expense. Errors: {errors}"
192
+
193
+ except ValueError as e:
194
+ return f"Error validation: {e}"
195
+ except Exception as e:
196
+ return f"Error adding expense: {e}"
197
+
198
+ @mcp.tool()
199
+ def delete_expense(expense_id: str) -> str:
200
+ """
201
+ Delete an expense by its ID.
202
+ """
203
+ if not client.client:
204
+ return "Error: Splitwise client not configured."
205
+
206
+ try:
207
+ client.delete_expense(expense_id)
208
+ return f"Successfully deleted expense {expense_id}."
209
+ except Exception as e:
210
+ return f"Error deleting expense: {e}"
211
+
212
+ def main():
213
+ mcp.run()
214
+
215
+ if __name__ == "__main__":
216
+ main()
217
+
splitwise_mcp/sse.py ADDED
@@ -0,0 +1,4 @@
1
+ from splitwise_mcp.server import mcp
2
+
3
+ # Expose the ASGI app for uvicorn
4
+ app = mcp.sse_app
@@ -0,0 +1,74 @@
1
+ from fastapi import FastAPI, HTTPException
2
+ from pydantic import BaseModel
3
+ from typing import List, Optional
4
+ from splitwise_mcp.client import SplitwiseClient
5
+ import os
6
+
7
+ app = FastAPI(title="Splitwise ChatGPT Connector", description="API to manage Splitwise expenses via ChatGPT")
8
+
9
+ # Global client
10
+ client = SplitwiseClient()
11
+
12
+ class ConfigureRequest(BaseModel):
13
+ consumer_key: Optional[str] = None
14
+ consumer_secret: Optional[str] = None
15
+ api_key: Optional[str] = None
16
+
17
+ class LoginTokenRequest(BaseModel):
18
+ access_token: str
19
+
20
+ class AddExpenseRequest(BaseModel):
21
+ amount: str
22
+ description: str
23
+ friend_names: List[str]
24
+
25
+ @app.get("/list_friends")
26
+ def list_friends():
27
+ """List all friends."""
28
+ if not client.client:
29
+ raise HTTPException(status_code=401, detail="Not configured. Please call /configure or /login_with_token first.")
30
+
31
+ try:
32
+ friends = client.get_friends()
33
+ output = []
34
+ for f in friends:
35
+ output.append({
36
+ "id": f.getId(),
37
+ "name": f"{f.getFirstName() or ''} {f.getLastName() or ''}".strip()
38
+ })
39
+ return {"friends": output}
40
+ except Exception as e:
41
+ raise HTTPException(status_code=500, detail=str(e))
42
+
43
+ @app.post("/add_expense")
44
+ def add_expense(req: AddExpenseRequest):
45
+ """Add an expense."""
46
+ if not client.client:
47
+ raise HTTPException(status_code=401, detail="Not configured.")
48
+
49
+ try:
50
+ expense = client.add_expense(req.amount, req.description, req.friend_names)
51
+ if expense:
52
+ return {"status": "success", "id": expense.getId(), "message": f"Added {req.amount} for {req.description}"}
53
+ else:
54
+ raise HTTPException(status_code=400, detail="Failed to add expense")
55
+ except Exception as e:
56
+ raise HTTPException(status_code=500, detail=str(e))
57
+
58
+ @app.post("/configure")
59
+ def configure(req: ConfigureRequest):
60
+ """Set API Keys manually."""
61
+ try:
62
+ client.configure(req.consumer_key, req.consumer_secret, req.api_key)
63
+ return {"status": "success", "message": "Configured successfully"}
64
+ except Exception as e:
65
+ raise HTTPException(status_code=400, detail=str(e))
66
+
67
+ @app.post("/login_with_token")
68
+ def login_with_token(req: LoginTokenRequest):
69
+ """Log in with OAuth2 token."""
70
+ try:
71
+ client.configure(access_token=req.access_token)
72
+ return {"status": "success", "message": "Logged in successfully"}
73
+ except Exception as e:
74
+ raise HTTPException(status_code=400, detail=str(e))
@@ -0,0 +1,19 @@
1
+ Metadata-Version: 2.4
2
+ Name: splitwise-mcp
3
+ Version: 0.1.0
4
+ Summary: MCP server for Splitwise integration
5
+ Author-email: User <user@example.com>
6
+ License-File: LICENSE
7
+ Requires-Python: >=3.10
8
+ Requires-Dist: colorama>=0.4.6
9
+ Requires-Dist: deepgram-sdk>=3.0.0
10
+ Requires-Dist: fastapi>=0.100.0
11
+ Requires-Dist: google-genai>=0.1.0
12
+ Requires-Dist: mcp[cli]>=0.1.0
13
+ Requires-Dist: numpy>=1.26.0
14
+ Requires-Dist: python-dotenv>=1.0.0
15
+ Requires-Dist: scipy>=1.11.0
16
+ Requires-Dist: sounddevice>=0.4.6
17
+ Requires-Dist: splitwise>=3.0.0
18
+ Requires-Dist: streamlit>=1.30.0
19
+ Requires-Dist: uvicorn>=0.20.0
@@ -0,0 +1,12 @@
1
+ splitwise_mcp/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ splitwise_mcp/client.py,sha256=YI-qlHmj1PnHNkF3f6Mi_HqrGBkJAAmejqEZG6HGtVU,11199
3
+ splitwise_mcp/server.py,sha256=n6C84WtlKrDvpCMGYL04H1mS98gblnbRcPE8rd8NN4M,7137
4
+ splitwise_mcp/sse.py,sha256=NUInFdjQlncZayOuSsG0G1B5A6Ql_CtDcSKZoLoQd78,90
5
+ splitwise_mcp/web_api.py,sha256=ZPkn2QXxtzAs7_h-schLoIg8c3AAra3eEnMexSquAMo,2543
6
+ splitwise_mcp/agent/audio.py,sha256=B6N2PnyPVRjZa3I4fnDuQG6xrEv1RMxO7klrHkyLs9w,3771
7
+ splitwise_mcp/agent/client.py,sha256=7uOBgwl6UkxTpzuSGffg9Bw7LRmLcEQk08vZYSaJG4w,13432
8
+ splitwise_mcp-0.1.0.dist-info/METADATA,sha256=3TxgVJ1rROCxs51rlBphTjMpGQBUmGXu2VJke6e4oWM,575
9
+ splitwise_mcp-0.1.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
10
+ splitwise_mcp-0.1.0.dist-info/entry_points.txt,sha256=GP-yZpuk1iE8D6Ajn2E7sLN1Vgy1bG4_jccZwNryRzc,60
11
+ splitwise_mcp-0.1.0.dist-info/licenses/LICENSE,sha256=SdEGDxmjXDxJeajJNuPh-JqOvJFxRPni9qTy2RczBkQ,1061
12
+ splitwise_mcp-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.28.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ splitwise-mcp = splitwise_mcp.server:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 User
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.