finpy-mcp 0.1.0__tar.gz

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,12 @@
1
+ Metadata-Version: 2.3
2
+ Name: finpy-mcp
3
+ Version: 0.1.0
4
+ Summary: Finpy MCP Server — AI-powered asset management through conversation
5
+ Author: Robert Radoslav
6
+ Author-email: Robert Radoslav <43938206+rbtrsv@users.noreply.github.com>
7
+ Requires-Dist: click>=8.3.2
8
+ Requires-Dist: fastmcp>=3.2.3
9
+ Requires-Dist: httpx>=0.28.1
10
+ Requires-Python: >=3.12
11
+ Description-Content-Type: text/markdown
12
+
File without changes
@@ -0,0 +1,22 @@
1
+ [project]
2
+ name = "finpy-mcp"
3
+ version = "0.1.0"
4
+ description = "Finpy MCP Server — AI-powered asset management through conversation"
5
+ readme = "README.md"
6
+ authors = [
7
+ { name = "Robert Radoslav", email = "43938206+rbtrsv@users.noreply.github.com" }
8
+ ]
9
+ requires-python = ">=3.12"
10
+ dependencies = [
11
+ "click>=8.3.2",
12
+ "fastmcp>=3.2.3",
13
+ "httpx>=0.28.1",
14
+ ]
15
+
16
+ [project.scripts]
17
+ finpy-mcp = "finpy_mcp.server:main"
18
+ finpy-cli = "finpy_mcp.cli:main"
19
+
20
+ [build-system]
21
+ requires = ["uv_build>=0.8.15,<0.9.0"]
22
+ build-backend = "uv_build"
@@ -0,0 +1,2 @@
1
+ def main() -> None:
2
+ print("Hello from finpy-mcp!")
@@ -0,0 +1,191 @@
1
+ """
2
+ Finpy CLI — Interactive terminal authentication
3
+
4
+ Commands:
5
+ finpy-cli login — Login via browser (OAuth device flow)
6
+ finpy-cli login --basic — Login with email/password in terminal (fallback)
7
+ finpy-cli logout — Delete stored credentials
8
+ finpy-cli set-org — Set active organization
9
+ finpy-cli status — Show current auth status
10
+ """
11
+
12
+ import getpass
13
+ import time
14
+ import webbrowser
15
+
16
+ import click
17
+ import httpx
18
+
19
+ from .config import API_BASE_URL, REQUEST_TIMEOUT
20
+ from .credentials import load_credentials, save_credentials, delete_credentials
21
+
22
+
23
+ @click.group()
24
+ def main():
25
+ """Finpy CLI — Authenticate and manage your Finpy MCP connection."""
26
+ pass
27
+
28
+
29
+ def _save_and_show(data: dict) -> None:
30
+ """Save credentials and show orgs. Shared between login flows."""
31
+ token = data["token"]
32
+ user = data["data"]["user"]
33
+ orgs = data["data"]["organizations"]
34
+
35
+ # Default to first organization
36
+ default_org_id = orgs[0]["id"] if orgs else None
37
+
38
+ save_credentials({
39
+ "access_token": token["access_token"],
40
+ "refresh_token": token["refresh_token"],
41
+ "organization_id": default_org_id,
42
+ "user_email": user["email"],
43
+ })
44
+
45
+ click.echo(f"\n✓ Logged in as {user['email']}")
46
+ click.echo(f"✓ Credentials saved to ~/.finpy/credentials.json\n")
47
+
48
+ if orgs:
49
+ click.echo("Organizations:")
50
+ for org in orgs:
51
+ marker = " (active)" if org["id"] == default_org_id else ""
52
+ click.echo(f" {org['id']}. {org['name']} — role: {org['user_role']}{marker}")
53
+ click.echo(f"\nUse 'finpy-cli set-org <id>' to change active organization.")
54
+ else:
55
+ click.echo("Warning: No organizations found. Create one in the Finpy dashboard.")
56
+
57
+
58
+ @main.command()
59
+ @click.option("--basic", is_flag=True, help="Use email/password login instead of browser (for headless environments)")
60
+ def login(basic: bool):
61
+ """Login to Finpy. Opens browser by default (OAuth device flow).
62
+ Use --basic for email/password in terminal."""
63
+ if basic:
64
+ _login_basic()
65
+ else:
66
+ _login_device()
67
+
68
+
69
+ def _login_device():
70
+ """Device flow: open browser, user approves, CLI polls for token."""
71
+ try:
72
+ with httpx.Client(timeout=REQUEST_TIMEOUT) as client:
73
+ # Step 1: Request device code
74
+ r = client.post(f"{API_BASE_URL}/accounts/auth/device-code")
75
+ r.raise_for_status()
76
+ device = r.json()
77
+
78
+ device_code = device["device_code"]
79
+ user_code = device["user_code"]
80
+ verification_url = device["verification_url"]
81
+ interval = device.get("interval", 2)
82
+ expires_in = device.get("expires_in", 300)
83
+
84
+ # Step 2: Open browser
85
+ click.echo(f"\nOpening browser to authorize...")
86
+ click.echo(f"If browser doesn't open, go to: {verification_url}")
87
+ click.echo(f"Code: {user_code}\n")
88
+ webbrowser.open(verification_url)
89
+
90
+ # Step 3: Poll for token
91
+ click.echo("Waiting for authorization", nl=False)
92
+ start = time.time()
93
+ while time.time() - start < expires_in:
94
+ time.sleep(interval)
95
+ click.echo(".", nl=False)
96
+
97
+ r = client.post(
98
+ f"{API_BASE_URL}/accounts/auth/device-token",
99
+ json={"device_code": device_code},
100
+ )
101
+ data = r.json()
102
+
103
+ if data.get("success"):
104
+ click.echo("") # newline after dots
105
+ _save_and_show(data)
106
+ return
107
+
108
+ error = data.get("error", "")
109
+ if error == "authorization_pending":
110
+ continue
111
+ elif error == "expired_token":
112
+ click.echo("\nError: Code expired. Please try again.", err=True)
113
+ raise SystemExit(1)
114
+ elif error == "access_denied":
115
+ click.echo("\nError: Authorization denied.", err=True)
116
+ raise SystemExit(1)
117
+ else:
118
+ click.echo(f"\nError: {error}", err=True)
119
+ raise SystemExit(1)
120
+
121
+ click.echo("\nError: Timed out waiting for authorization.", err=True)
122
+ raise SystemExit(1)
123
+
124
+ except httpx.ConnectError:
125
+ click.echo(f"Error: Cannot connect to {API_BASE_URL}. Is the server running?", err=True)
126
+ raise SystemExit(1)
127
+
128
+
129
+ def _login_basic():
130
+ """Basic flow: email/password in terminal (fallback for headless environments)."""
131
+ email = click.prompt("Email")
132
+ password = getpass.getpass("Password: ")
133
+
134
+ click.echo("Logging in...")
135
+
136
+ try:
137
+ with httpx.Client(timeout=REQUEST_TIMEOUT) as client:
138
+ r = client.post(
139
+ f"{API_BASE_URL}/accounts/auth/login",
140
+ json={"email": email, "password": password},
141
+ )
142
+
143
+ if r.status_code == 401:
144
+ click.echo("Error: Invalid email or password.", err=True)
145
+ raise SystemExit(1)
146
+
147
+ r.raise_for_status()
148
+ _save_and_show(r.json())
149
+
150
+ except httpx.HTTPStatusError as e:
151
+ click.echo(f"Error: API returned {e.response.status_code}", err=True)
152
+ raise SystemExit(1)
153
+ except httpx.ConnectError:
154
+ click.echo(f"Error: Cannot connect to {API_BASE_URL}. Is the server running?", err=True)
155
+ raise SystemExit(1)
156
+
157
+
158
+ @main.command()
159
+ def logout():
160
+ """Delete stored credentials."""
161
+ delete_credentials()
162
+ click.echo("✓ Logged out. Credentials deleted.")
163
+
164
+
165
+ @main.command("set-org")
166
+ @click.argument("org_id", type=int)
167
+ def set_org(org_id: int):
168
+ """Set the active organization ID."""
169
+ creds = load_credentials()
170
+ if not creds:
171
+ click.echo("Error: Not logged in. Run 'finpy-cli login' first.", err=True)
172
+ raise SystemExit(1)
173
+
174
+ creds["organization_id"] = org_id
175
+ save_credentials(creds)
176
+ click.echo(f"✓ Active organization set to {org_id}")
177
+
178
+
179
+ @main.command()
180
+ def status():
181
+ """Show current authentication status."""
182
+ creds = load_credentials()
183
+ if not creds:
184
+ click.echo("Not authenticated. Run 'finpy-cli login' first.")
185
+ return
186
+
187
+ click.echo(f"Email: {creds.get('user_email', 'unknown')}")
188
+ click.echo(f"Organization ID: {creds.get('organization_id', 'not set')}")
189
+ click.echo(f"API: {API_BASE_URL}")
190
+ click.echo(f"Token: {'present' if creds.get('access_token') else 'missing'}")
191
+ click.echo(f"Refresh token: {'present' if creds.get('refresh_token') else 'missing'}")
@@ -0,0 +1,122 @@
1
+ """
2
+ Finpy MCP Server — HTTP Client
3
+
4
+ Async httpx wrapper that handles:
5
+ - JWT authentication (from ~/.finpy/credentials.json)
6
+ - Organization context (query param)
7
+ - Entity context (for tools that need it)
8
+ - Automatic token refresh on 401 responses
9
+ """
10
+
11
+ import httpx
12
+
13
+ from .config import API_BASE_URL, REQUEST_TIMEOUT
14
+ from .credentials import load_credentials, save_credentials
15
+
16
+
17
+ class FinpyClient:
18
+ """HTTP client for Finpy API with JWT auth and org/entity context."""
19
+
20
+ def __init__(self) -> None:
21
+ self.base_url = API_BASE_URL
22
+ self.organization_id: int | None = None
23
+ self.entity_id: int | None = None
24
+
25
+ def _get_credentials(self) -> dict:
26
+ """Load credentials or raise descriptive error."""
27
+ creds = load_credentials()
28
+ if not creds or not creds.get("access_token"):
29
+ raise RuntimeError(
30
+ "Not authenticated. Please run 'finpy-cli login' in your terminal first."
31
+ )
32
+ return creds
33
+
34
+ @property
35
+ def _headers(self) -> dict:
36
+ """Auth headers from stored credentials."""
37
+ creds = self._get_credentials()
38
+ return {"Authorization": f"Bearer {creds['access_token']}"}
39
+
40
+ @property
41
+ def _params(self) -> dict:
42
+ """Default query params (organization_id)."""
43
+ # Use org from credentials if not overridden
44
+ org_id = self.organization_id
45
+ if org_id is None:
46
+ creds = load_credentials()
47
+ if creds:
48
+ org_id = creds.get("organization_id")
49
+ params: dict = {}
50
+ if org_id is not None:
51
+ params["organization_id"] = org_id
52
+ return params
53
+
54
+ async def _refresh_token(self) -> bool:
55
+ """Attempt to refresh JWT using refresh_token. Returns True if successful."""
56
+ creds = load_credentials()
57
+ if not creds or not creds.get("refresh_token"):
58
+ return False
59
+
60
+ try:
61
+ async with httpx.AsyncClient(timeout=REQUEST_TIMEOUT) as http:
62
+ r = await http.post(
63
+ f"{self.base_url}/accounts/auth/refresh-token",
64
+ json={"refresh_token": creds["refresh_token"]},
65
+ )
66
+ if r.status_code == 200:
67
+ data = r.json()
68
+ creds["access_token"] = data["token"]["access_token"]
69
+ creds["refresh_token"] = data["token"]["refresh_token"]
70
+ save_credentials(creds)
71
+ return True
72
+ except httpx.HTTPError:
73
+ pass
74
+ return False
75
+
76
+ async def _request(self, method: str, path: str, **kwargs) -> dict:
77
+ """Make HTTP request with auto-refresh on 401."""
78
+ params = {**self._params, **kwargs.pop("params", {})}
79
+
80
+ async with httpx.AsyncClient(timeout=REQUEST_TIMEOUT) as http:
81
+ r = await http.request(
82
+ method,
83
+ f"{self.base_url}{path}",
84
+ headers=self._headers,
85
+ params=params,
86
+ **kwargs,
87
+ )
88
+
89
+ # Auto-refresh on 401
90
+ if r.status_code == 401:
91
+ refreshed = await self._refresh_token()
92
+ if refreshed:
93
+ r = await http.request(
94
+ method,
95
+ f"{self.base_url}{path}",
96
+ headers=self._headers,
97
+ params=params,
98
+ **kwargs,
99
+ )
100
+ else:
101
+ raise RuntimeError(
102
+ "Session expired. Please run 'finpy-cli login' again."
103
+ )
104
+
105
+ r.raise_for_status()
106
+ return r.json()
107
+
108
+ async def get(self, path: str, **kwargs) -> dict:
109
+ """GET request to Finpy API."""
110
+ return await self._request("GET", path, **kwargs)
111
+
112
+ async def post(self, path: str, data: dict) -> dict:
113
+ """POST request to Finpy API."""
114
+ return await self._request("POST", path, json=data)
115
+
116
+ async def put(self, path: str, data: dict) -> dict:
117
+ """PUT request to Finpy API."""
118
+ return await self._request("PUT", path, json=data)
119
+
120
+
121
+ # Singleton instance — shared across all tools
122
+ finpy = FinpyClient()
@@ -0,0 +1,17 @@
1
+ """
2
+ Finpy MCP Server — Configuration
3
+
4
+ API base URL is read from FINPY_API_URL env var.
5
+ Defaults to localhost for development, override for production.
6
+ """
7
+
8
+ import os
9
+
10
+ # API base URL — override with FINPY_API_URL env var for production
11
+ API_BASE_URL = os.environ.get("FINPY_API_URL", "http://localhost:8001")
12
+
13
+ # Credentials file path — where JWT + refresh token are stored locally
14
+ CREDENTIALS_PATH = os.path.expanduser("~/.finpy/credentials.json")
15
+
16
+ # HTTP client timeout (seconds)
17
+ REQUEST_TIMEOUT = 30.0
@@ -0,0 +1,50 @@
1
+ """
2
+ Finpy MCP Server — Credentials Management
3
+
4
+ Reads/writes JWT + refresh token to ~/.finpy/credentials.json.
5
+ File permissions set to 600 (owner read/write only).
6
+ Password is NEVER stored — only tokens.
7
+ """
8
+
9
+ import json
10
+ import os
11
+ from pathlib import Path
12
+ from typing import TypedDict
13
+
14
+ from .config import CREDENTIALS_PATH
15
+
16
+
17
+ class Credentials(TypedDict):
18
+ access_token: str
19
+ refresh_token: str
20
+ organization_id: int | None
21
+ user_email: str
22
+
23
+
24
+ def load_credentials() -> Credentials | None:
25
+ """Load credentials from ~/.finpy/credentials.json. Returns None if not found."""
26
+ path = Path(CREDENTIALS_PATH)
27
+ if not path.exists():
28
+ return None
29
+ try:
30
+ with open(path) as f:
31
+ return json.load(f)
32
+ except (json.JSONDecodeError, KeyError):
33
+ return None
34
+
35
+
36
+ def save_credentials(creds: Credentials) -> None:
37
+ """Save credentials to ~/.finpy/credentials.json with chmod 600."""
38
+ path = Path(CREDENTIALS_PATH)
39
+ path.parent.mkdir(parents=True, exist_ok=True)
40
+ with open(path, "w") as f:
41
+ json.dump(creds, f, indent=2)
42
+ # Owner read/write only — no group/other access
43
+ os.chmod(path, 0o600)
44
+
45
+
46
+ def delete_credentials() -> None:
47
+ """Delete credentials file (logout)."""
48
+ path = Path(CREDENTIALS_PATH)
49
+ if path.exists():
50
+ path.unlink()
@@ -0,0 +1,35 @@
1
+ """
2
+ Finpy MCP Server
3
+
4
+ FastMCP instance with all tools registered.
5
+ Entrypoint: finpy-mcp (stdio transport for Claude Desktop/Code/Cursor)
6
+ """
7
+
8
+ from fastmcp import FastMCP
9
+
10
+ mcp = FastMCP(
11
+ "Finpy",
12
+ instructions=(
13
+ "Finpy Asset Manager — manage cap tables, deals, holdings, and financials through conversation.\n\n"
14
+ "IMPORTANT: Before using any tools, the user must have run 'finpy-cli login' in their terminal.\n"
15
+ "Then use set_context() to select an organization and entity.\n\n"
16
+ "Workflow: set_context → list/create data.\n"
17
+ "Entity is the foundation — everything (stakeholders, securities, deals, holdings) belongs to an entity.\n"
18
+ "Subscription is charged per entity — inform users when creating new entities."
19
+ ),
20
+ mask_error_details=True,
21
+ )
22
+
23
+ # Register all tools by importing tool modules
24
+ # Each module uses @mcp.tool decorator from this instance
25
+ from .tools import context # noqa: F401, E402
26
+ from .tools import entity # noqa: F401, E402
27
+ from .tools import captable # noqa: F401, E402
28
+ from .tools import deals # noqa: F401, E402
29
+ from .tools import holdings # noqa: F401, E402
30
+ from .tools import financials # noqa: F401, E402
31
+
32
+
33
+ def main():
34
+ """Entrypoint for finpy-mcp command. Runs stdio transport."""
35
+ mcp.run()
File without changes