finta-aurora-mcp 1.0.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.
@@ -0,0 +1,3 @@
1
+ """Finta Aurora MCP - Chat with Aurora AI from Cursor/Claude Desktop."""
2
+
3
+ __version__ = "1.0.0"
@@ -0,0 +1,105 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Aurora MCP Authentication
4
+ Simple OAuth login - opens browser and stores token.
5
+ """
6
+
7
+ import os
8
+ import sys
9
+ import webbrowser
10
+ import http.server
11
+ import socketserver
12
+ import urllib.parse
13
+ import json
14
+ import requests
15
+ from pathlib import Path
16
+
17
+ FINTA_API_URL_STAGING = "https://us-central1-equity-token-stage.cloudfunctions.net"
18
+ FINTA_API_URL_PRODUCTION = "https://us-central1-equity-token.cloudfunctions.net"
19
+
20
+ def get_api_url():
21
+ """Get API URL based on environment."""
22
+ use_staging = os.getenv("FINTA_STAGING", "true").lower() == "true"
23
+ return FINTA_API_URL_STAGING if use_staging else FINTA_API_URL_PRODUCTION
24
+
25
+ def get_token_path():
26
+ """Get path to store token."""
27
+ return Path.home() / ".cursor" / "aurora_token.json"
28
+
29
+ def main():
30
+ print("Aurora MCP Authentication")
31
+ print("=" * 40)
32
+ print("Opening browser for login...\n")
33
+
34
+ api_url = get_api_url()
35
+ auth_url = (
36
+ f"{api_url}/auroraOAuthAuthorize?"
37
+ f"response_type=code&"
38
+ f"client_id=cursor&"
39
+ f"redirect_uri=http://localhost:8765/callback"
40
+ )
41
+
42
+ webbrowser.open(auth_url)
43
+
44
+ class CallbackHandler(http.server.SimpleHTTPRequestHandler):
45
+ def do_GET(self):
46
+ query = urllib.parse.urlparse(self.path).query
47
+ params = urllib.parse.parse_qs(query)
48
+
49
+ if 'code' in params:
50
+ code = params['code'][0]
51
+ api_url = get_api_url()
52
+
53
+ try:
54
+ resp = requests.post(
55
+ f"{api_url}/auroraOAuthToken",
56
+ json={
57
+ "grant_type": "authorization_code",
58
+ "code": code,
59
+ "redirect_uri": "http://localhost:8765/callback"
60
+ },
61
+ timeout=10
62
+ )
63
+
64
+ if resp.status_code == 200:
65
+ token_data = resp.json()
66
+ access_token = token_data.get("access_token")
67
+
68
+ token_path = get_token_path()
69
+ token_path.parent.mkdir(exist_ok=True)
70
+ with open(token_path, 'w') as f:
71
+ json.dump(token_data, f, indent=2)
72
+
73
+ self.send_response(200)
74
+ self.send_header('Content-type', 'text/html; charset=utf-8')
75
+ self.end_headers()
76
+ html = """<html><body style="font-family: system-ui; padding: 40px; text-align: center;"><h1>Authentication Successful!</h1><p>Token has been stored.</p><p>You can close this window and restart Cursor.</p></body></html>"""
77
+ self.wfile.write(html.encode('utf-8'))
78
+ print("\n✓ Authentication successful!")
79
+ print(f"✓ Token stored at: {token_path}")
80
+ print("\nRestart Cursor to use Aurora MCP.")
81
+ return
82
+ else:
83
+ print(f"\n✗ Failed to get token: {resp.status_code} - {resp.text}")
84
+ except Exception as e:
85
+ print(f"\n✗ Error: {e}")
86
+
87
+ self.send_response(200)
88
+ self.send_header('Content-type', 'text/html')
89
+ self.end_headers()
90
+ self.wfile.write(b"<h1>Processing...</h1>")
91
+
92
+ def log_message(self, *args):
93
+ pass
94
+
95
+ with socketserver.TCPServer(("", 8765), CallbackHandler) as httpd:
96
+ print("Waiting for authentication callback...")
97
+ print("(Press Ctrl+C to cancel)\n")
98
+ try:
99
+ httpd.handle_request()
100
+ except KeyboardInterrupt:
101
+ print("\n\n✗ Authentication cancelled.")
102
+ sys.exit(1)
103
+
104
+ if __name__ == "__main__":
105
+ main()
@@ -0,0 +1,44 @@
1
+ #!/usr/bin/env python3
2
+ """Fix org_info file to add missing founderId field."""
3
+
4
+ import json
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ ORG_INFO_PATH = Path.home() / ".cursor" / "aurora_org_info.json"
9
+
10
+ def fix_org_info():
11
+ """Add founderId to org_info if missing."""
12
+ if not ORG_INFO_PATH.exists():
13
+ print("Error: org_info file does not exist. Please run: aurora-authenticate")
14
+ return False
15
+
16
+ try:
17
+ with open(ORG_INFO_PATH) as f:
18
+ org_info = json.load(f)
19
+
20
+ print(f"Current org_info: {json.dumps(org_info, indent=2)}")
21
+
22
+ # Add founderId if missing
23
+ if not org_info.get("founderId"):
24
+ founder_id = org_info.get("user_id") or org_info.get("userId")
25
+ if founder_id:
26
+ org_info["founderId"] = founder_id
27
+ with open(ORG_INFO_PATH, 'w') as f:
28
+ json.dump(org_info, f, indent=2)
29
+ print(f"\n✅ Updated org_info with founderId: {founder_id}")
30
+ print(f"Updated org_info: {json.dumps(org_info, indent=2)}")
31
+ return True
32
+ else:
33
+ print("\n❌ Error: No user_id found in org_info. Please re-authenticate with: aurora-authenticate")
34
+ return False
35
+ else:
36
+ print(f"\n✅ org_info already has founderId: {org_info.get('founderId')}")
37
+ return True
38
+ except Exception as e:
39
+ print(f"Error fixing org_info: {e}", file=sys.stderr)
40
+ return False
41
+
42
+ if __name__ == "__main__":
43
+ success = fix_org_info()
44
+ sys.exit(0 if success else 1)
@@ -0,0 +1,498 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Aurora MCP Proxy Server
4
+
5
+ This creates a local MCP server that proxies requests to the Aurora server
6
+ with authentication. Similar to how Firebase MCP handles auth via CLI login.
7
+ """
8
+
9
+ import asyncio
10
+ import json
11
+ import sys
12
+ import os
13
+ from pathlib import Path
14
+
15
+ import httpx
16
+ from mcp.server import Server
17
+ from mcp.server.stdio import stdio_server
18
+ from mcp.types import Tool, TextContent
19
+
20
+ AURORA_URL = "https://aurora-v1-staging-12f0e746ba305c099eb77fd9f5494283.us.langgraph.app"
21
+ TOKEN_PATH = Path.home() / ".cursor" / "aurora_token.json"
22
+ ORG_INFO_PATH = Path.home() / ".cursor" / "aurora_org_info.json"
23
+ FINTA_API_URL = "https://us-central1-equity-token-stage.cloudfunctions.net"
24
+
25
+ def get_token():
26
+ """Load stored token."""
27
+ try:
28
+ if TOKEN_PATH.exists():
29
+ with open(TOKEN_PATH) as f:
30
+ data = json.load(f)
31
+ return data.get("access_token")
32
+ except Exception as e:
33
+ print(f"Error loading token: {e}", file=sys.stderr)
34
+ return None
35
+
36
+ def get_org_info():
37
+ """Load stored org info."""
38
+ try:
39
+ if ORG_INFO_PATH.exists():
40
+ with open(ORG_INFO_PATH) as f:
41
+ return json.load(f)
42
+ except Exception as e:
43
+ print(f"Error loading org info: {e}", file=sys.stderr)
44
+ return None
45
+
46
+ async def fetch_and_store_org_info(token: str):
47
+ """Fetch org info from the validation endpoint and store it."""
48
+ try:
49
+ async with httpx.AsyncClient() as client:
50
+ response = await client.post(
51
+ f"{FINTA_API_URL}/validateAuroraOAuthToken",
52
+ headers={"Authorization": f"Bearer {token}"},
53
+ timeout=10.0
54
+ )
55
+ if response.status_code == 200:
56
+ data = response.json()
57
+ if data.get("valid"):
58
+ user_id = data.get("userId")
59
+ org_info = {
60
+ "organization_id": data.get("organizationId"),
61
+ "organization_name": data.get("organizationName"),
62
+ "user_id": user_id,
63
+ "founderId": user_id, # Required by Aurora tools (e.g., editInvestorTool)
64
+ "handle": data.get("organizationId"),
65
+ }
66
+ with open(ORG_INFO_PATH, 'w') as f:
67
+ json.dump(org_info, f, indent=2)
68
+ return org_info
69
+ except Exception as e:
70
+ print(f"Error fetching org info: {e}", file=sys.stderr)
71
+ return None
72
+
73
+ async def fetch_deal_info(org_info: dict, token: str = None) -> str:
74
+ """Fetch deal info for the organization (authenticated)."""
75
+ if not org_info:
76
+ return ""
77
+
78
+ handle = org_info.get("handle") or org_info.get("organization_id")
79
+ if not handle:
80
+ return ""
81
+
82
+ # Get token if not provided
83
+ if not token:
84
+ token = get_token()
85
+
86
+ try:
87
+ async with httpx.AsyncClient() as client:
88
+ url = f"{FINTA_API_URL}/fintaAI/tools/get-deal-info"
89
+ headers = {}
90
+ if token:
91
+ headers["Authorization"] = f"Bearer {token}"
92
+ response = await client.get(
93
+ url,
94
+ params={"organizationHandle": handle},
95
+ headers=headers,
96
+ timeout=15.0
97
+ )
98
+ if response.status_code == 200:
99
+ data = response.json()
100
+ return data.get("formatted", "")
101
+ elif response.status_code == 401:
102
+ print(f"Auth error fetching deal info: {response.text}", file=sys.stderr)
103
+ except Exception as e:
104
+ print(f"Error fetching deal info: {e}", file=sys.stderr)
105
+ return ""
106
+
107
+ async def fetch_crm_contacts(org_info: dict, token: str = None) -> str:
108
+ """Fetch CRM contacts for the organization (authenticated)."""
109
+ if not org_info:
110
+ return ""
111
+
112
+ handle = org_info.get("handle") or org_info.get("organization_id")
113
+ if not handle:
114
+ return ""
115
+
116
+ # Get token if not provided
117
+ if not token:
118
+ token = get_token()
119
+
120
+ try:
121
+ async with httpx.AsyncClient() as client:
122
+ url = f"{FINTA_API_URL}/fintaAI/tools/list-investors"
123
+ headers = {}
124
+ if token:
125
+ headers["Authorization"] = f"Bearer {token}"
126
+ response = await client.get(
127
+ url,
128
+ params={"organizationHandle": handle},
129
+ headers=headers,
130
+ timeout=15.0
131
+ )
132
+ if response.status_code == 200:
133
+ data = response.json()
134
+ return data.get("formatted", "") or data.get("formattedInvestors", "")
135
+ elif response.status_code == 401:
136
+ print(f"Auth error fetching CRM contacts: {response.text}", file=sys.stderr)
137
+ except Exception as e:
138
+ print(f"Error fetching CRM contacts: {e}", file=sys.stderr)
139
+ return ""
140
+
141
+ server = Server("aurora-proxy")
142
+
143
+ @server.list_tools()
144
+ async def list_tools():
145
+ """List available Aurora tools."""
146
+ token = get_token()
147
+ if not token:
148
+ return [
149
+ Tool(
150
+ name="aurora_login",
151
+ description="You need to authenticate first. Run: aurora-authenticate",
152
+ inputSchema={"type": "object", "properties": {}}
153
+ )
154
+ ]
155
+
156
+ # Always return aurora_chat tool (primary interface)
157
+ # The /mcp endpoint might not exist, so we don't rely on it
158
+ return [
159
+ Tool(
160
+ name="aurora_chat",
161
+ description="Chat with Aurora AI assistant. Aurora has access to tools like searching the web, scraping websites, querying knowledge bases, managing CRM contacts, and more.",
162
+ inputSchema={
163
+ "type": "object",
164
+ "properties": {
165
+ "message": {
166
+ "type": "string",
167
+ "description": "Your message to Aurora. Aurora will automatically use tools as needed to answer your question."
168
+ }
169
+ },
170
+ "required": ["message"]
171
+ }
172
+ )
173
+ ]
174
+
175
+ @server.call_tool()
176
+ async def call_tool(name: str, arguments: dict):
177
+ """Handle tool calls."""
178
+ token = get_token()
179
+ if not token:
180
+ return [TextContent(
181
+ type="text",
182
+ text="Not authenticated. Run: aurora-authenticate"
183
+ )]
184
+
185
+ if name == "aurora_chat":
186
+ return await handle_aurora_chat(token, arguments)
187
+
188
+ try:
189
+ async with httpx.AsyncClient() as client:
190
+ response = await client.post(
191
+ f"{AURORA_URL}/mcp",
192
+ headers={
193
+ "Authorization": f"Bearer {token}",
194
+ "Content-Type": "application/json",
195
+ "Accept": "application/json"
196
+ },
197
+ json={
198
+ "jsonrpc": "2.0",
199
+ "method": "tools/call",
200
+ "params": {
201
+ "name": name,
202
+ "arguments": arguments
203
+ },
204
+ "id": 1
205
+ },
206
+ timeout=60.0
207
+ )
208
+
209
+ if response.status_code == 200:
210
+ data = response.json()
211
+ if "result" in data and "content" in data["result"]:
212
+ return data["result"]["content"]
213
+ elif "error" in data:
214
+ return [TextContent(
215
+ type="text",
216
+ text=f"Error: {data['error'].get('message', 'Unknown error')}"
217
+ )]
218
+ else:
219
+ return [TextContent(
220
+ type="text",
221
+ text=f"Aurora server error: {response.status_code} - {response.text}"
222
+ )]
223
+ except Exception as e:
224
+ return [TextContent(
225
+ type="text",
226
+ text=f"Error calling Aurora: {str(e)}"
227
+ )]
228
+
229
+ return [TextContent(type="text", text="No response from Aurora")]
230
+
231
+ async def handle_aurora_chat(token: str, arguments: dict):
232
+ """Handle aurora_chat by calling Aurora's thread API."""
233
+ message = arguments.get("message", "")
234
+ if not message:
235
+ return [TextContent(type="text", text="Error: message is required")]
236
+
237
+ org_info = get_org_info()
238
+ if not org_info:
239
+ org_info = await fetch_and_store_org_info(token)
240
+
241
+ # Ensure org_info has founderId (required by Aurora tools)
242
+ # If missing, try to use user_id as fallback and save it
243
+ if org_info:
244
+ if not org_info.get("founderId"):
245
+ # Try user_id as fallback
246
+ founder_id = org_info.get("user_id") or org_info.get("userId")
247
+ if founder_id:
248
+ org_info["founderId"] = founder_id
249
+ # Save updated org_info back to file
250
+ try:
251
+ with open(ORG_INFO_PATH, 'w') as f:
252
+ json.dump(org_info, f, indent=2)
253
+ except Exception as e:
254
+ print(f"Warning: Could not save updated org_info: {e}", file=sys.stderr)
255
+
256
+ # If still missing, try to fetch fresh org_info
257
+ if not org_info.get("founderId"):
258
+ org_info = await fetch_and_store_org_info(token)
259
+
260
+ # Validate that we have the required fields
261
+ if not org_info or not org_info.get("founderId") or not org_info.get("handle"):
262
+ return [TextContent(
263
+ type="text",
264
+ text="Error: Missing required organization information (founderId or handle). Please re-authenticate with: aurora-authenticate"
265
+ )]
266
+
267
+ try:
268
+ async with httpx.AsyncClient() as client:
269
+ # Create a fresh thread for each request to avoid state corruption issues
270
+ # This ensures clean state for tool call handling
271
+ response = await client.post(
272
+ f"{AURORA_URL}/threads",
273
+ headers={
274
+ "Authorization": f"Bearer {token}",
275
+ "Content-Type": "application/json"
276
+ },
277
+ json={},
278
+ timeout=10.0
279
+ )
280
+ if response.status_code == 200:
281
+ data = response.json()
282
+ thread_id = data.get("thread_id")
283
+ if not thread_id:
284
+ return [TextContent(
285
+ type="text",
286
+ text="Error: No thread_id returned from Aurora"
287
+ )]
288
+ else:
289
+ return [TextContent(
290
+ type="text",
291
+ text=f"Error creating thread: {response.status_code} - {response.text}"
292
+ )]
293
+
294
+ deal_info_text, crm_contacts = await asyncio.gather(
295
+ fetch_deal_info(org_info, token),
296
+ fetch_crm_contacts(org_info, token)
297
+ )
298
+
299
+ deal_info_parts = []
300
+ if deal_info_text:
301
+ deal_info_parts.append(deal_info_text)
302
+
303
+ if crm_contacts:
304
+ crm_section = f"\n=== MY CRM CONTACTS ===\nBelow is a complete list of all investors in my CRM. When asked \"who is in my CRM\", \"list my contacts\", or similar questions, use this data directly to answer.\nColumns: Investor Name; Organization Name; Status; Amount; Time Engaged; Email\n{crm_contacts}\n=== END CRM CONTACTS ==="
305
+ deal_info_parts.append(crm_section)
306
+
307
+ deal_info = "\n".join(deal_info_parts)
308
+
309
+ # Ensure org_info is properly formatted for Aurora tools
310
+ # Double-check founderId is present before sending to Aurora
311
+ if not org_info.get("founderId"):
312
+ # Last resort: try to get it from user_id
313
+ founder_id = org_info.get("user_id") or org_info.get("userId")
314
+ if founder_id:
315
+ org_info["founderId"] = founder_id
316
+ print(f"Added founderId dynamically: {founder_id}", file=sys.stderr)
317
+ else:
318
+ print(f"ERROR: org_info missing founderId and user_id: {org_info}", file=sys.stderr)
319
+ return [TextContent(
320
+ type="text",
321
+ text="Error: Missing founderId in organization info. Please re-authenticate with: aurora-authenticate"
322
+ )]
323
+
324
+ # Debug: Verify org_info structure
325
+ print(f"DEBUG: Sending org_info to Aurora: founderId={org_info.get('founderId')}, handle={org_info.get('handle')}", file=sys.stderr)
326
+
327
+ run_config = {
328
+ "configurable": {
329
+ "model_name": "gpt-5-mini",
330
+ "org_info": org_info, # This must include founderId and handle
331
+ "deal_info": deal_info,
332
+ }
333
+ }
334
+
335
+ # Use the streaming endpoint (matches webapp's approach)
336
+ # This ensures proper tool call handling
337
+ response = await client.post(
338
+ f"{AURORA_URL}/threads/{thread_id}/runs/stream",
339
+ headers={
340
+ "Authorization": f"Bearer {token}",
341
+ "Content-Type": "application/json",
342
+ "Accept": "text/event-stream"
343
+ },
344
+ json={
345
+ "assistant_id": "agent",
346
+ "input": {
347
+ "messages": [{"role": "human", "content": message}]
348
+ },
349
+ "config": run_config,
350
+ "stream_mode": "messages"
351
+ },
352
+ timeout=180.0
353
+ )
354
+
355
+ if response.status_code != 200:
356
+ return [TextContent(
357
+ type="text",
358
+ text=f"Aurora server error: {response.status_code} - {response.text}"
359
+ )]
360
+
361
+ last_ai_message = None
362
+ buffer = ""
363
+ stream_complete = False
364
+
365
+ # Process the stream completely - this ensures all tool calls are executed
366
+ try:
367
+ async for chunk in response.aiter_bytes():
368
+ if not chunk:
369
+ continue
370
+
371
+ buffer += chunk.decode('utf-8', errors='ignore')
372
+ lines = buffer.split('\n')
373
+ buffer = lines.pop() if lines else ""
374
+
375
+ for line in lines:
376
+ line = line.strip()
377
+ if not line or line.startswith(':'):
378
+ continue
379
+
380
+ if line.startswith('data: '):
381
+ data_str = line[6:]
382
+ if data_str == '[DONE]':
383
+ stream_complete = True
384
+ continue
385
+
386
+ try:
387
+ data = json.loads(data_str)
388
+
389
+ # Handle different stream event types
390
+ if isinstance(data, dict):
391
+ # Check for run status updates
392
+ if data.get("event") == "end":
393
+ stream_complete = True
394
+ continue
395
+
396
+ # Handle messages array from stream
397
+ if isinstance(data, list):
398
+ for item in data:
399
+ role = item.get("role") or item.get("type", "")
400
+
401
+ # Skip tool messages and user messages
402
+ if role in ["tool", "user"]:
403
+ continue
404
+
405
+ # Collect AI/assistant messages (only those with actual content)
406
+ if role in ["ai", "assistant"]:
407
+ # Skip messages that only have tool calls (wait for tool execution to complete)
408
+ if (item.get("tool_calls") or item.get("additional_kwargs", {}).get("tool_calls")) and not item.get("content"):
409
+ continue
410
+
411
+ content = item.get("content", "")
412
+
413
+ # Extract text content
414
+ if isinstance(content, list):
415
+ text_parts = []
416
+ for part in content:
417
+ if isinstance(part, dict):
418
+ text_parts.append(part.get("text", str(part)))
419
+ else:
420
+ text_parts.append(str(part))
421
+ content = " ".join(text_parts)
422
+ elif isinstance(content, dict):
423
+ content = content.get("text", str(content))
424
+
425
+ if content and isinstance(content, str) and content.strip():
426
+ last_ai_message = content
427
+ except json.JSONDecodeError:
428
+ continue
429
+ except Exception as stream_error:
430
+ # Stream error - fall back to thread state
431
+ pass
432
+
433
+ # Wait a moment for any final processing
434
+ if not stream_complete:
435
+ await asyncio.sleep(2)
436
+
437
+ # Always get final state from thread to ensure we have the complete response
438
+ thread_response = await client.get(
439
+ f"{AURORA_URL}/threads/{thread_id}/state",
440
+ headers={"Authorization": f"Bearer {token}"},
441
+ timeout=10.0
442
+ )
443
+
444
+ if thread_response.status_code == 200:
445
+ thread_data = thread_response.json()
446
+ messages = thread_data.get("values", {}).get("messages", [])
447
+
448
+ # Find the last AI message that has actual text content (after all tool calls)
449
+ for msg in reversed(messages):
450
+ role = msg.get("role") or msg.get("type", "")
451
+ if role in ["ai", "assistant"]:
452
+ # Only return messages that have content (not just tool calls)
453
+ # Tool calls should have been executed by now
454
+ if msg.get("tool_calls") and not msg.get("content"):
455
+ continue
456
+
457
+ content = msg.get("content", "")
458
+ if isinstance(content, list):
459
+ text_parts = []
460
+ for item in content:
461
+ if isinstance(item, dict):
462
+ text_parts.append(item.get("text", str(item)))
463
+ else:
464
+ text_parts.append(str(item))
465
+ content = " ".join(text_parts)
466
+ elif isinstance(content, dict):
467
+ content = content.get("text", str(content))
468
+
469
+ if content and isinstance(content, str) and content.strip():
470
+ return [TextContent(type="text", text=str(content))]
471
+
472
+ # If we got a message from the stream, use it
473
+ if last_ai_message:
474
+ return [TextContent(type="text", text=last_ai_message)]
475
+
476
+ return [TextContent(
477
+ type="text",
478
+ text="Aurora completed but no text response found. The response may have been tool calls only."
479
+ )]
480
+ except Exception as e:
481
+ return [TextContent(
482
+ type="text",
483
+ text=f"Error calling Aurora: {str(e)}"
484
+ )]
485
+
486
+ return [TextContent(type="text", text="No response from Aurora")]
487
+
488
+ async def main():
489
+ """Run the MCP server."""
490
+ async with stdio_server() as (read_stream, write_stream):
491
+ await server.run(
492
+ read_stream,
493
+ write_stream,
494
+ server.create_initialization_options()
495
+ )
496
+
497
+ if __name__ == "__main__":
498
+ asyncio.run(main())