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.
- splitwise_mcp/__init__.py +0 -0
- splitwise_mcp/agent/audio.py +105 -0
- splitwise_mcp/agent/client.py +281 -0
- splitwise_mcp/client.py +285 -0
- splitwise_mcp/server.py +217 -0
- splitwise_mcp/sse.py +4 -0
- splitwise_mcp/web_api.py +74 -0
- splitwise_mcp-0.1.0.dist-info/METADATA +19 -0
- splitwise_mcp-0.1.0.dist-info/RECORD +12 -0
- splitwise_mcp-0.1.0.dist-info/WHEEL +4 -0
- splitwise_mcp-0.1.0.dist-info/entry_points.txt +2 -0
- splitwise_mcp-0.1.0.dist-info/licenses/LICENSE +21 -0
|
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
|
splitwise_mcp/client.py
ADDED
|
@@ -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
|
+
|
splitwise_mcp/server.py
ADDED
|
@@ -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
splitwise_mcp/web_api.py
ADDED
|
@@ -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,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.
|