File size: 8,835 Bytes
d3d8830
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
"""
src/mcp_server/server.py
Finnie MCP Server β€” exposes all 6 finance agents as tools for Claude Desktop.

Each tool is one of Finnie's agents. Claude Desktop calls them directly;
no LangGraph router needed here since Claude IS the reasoner.

Add to ~/Library/Application Support/Claude/claude_desktop_config.json:
    {
      "mcpServers": {
        "finnie": {
          "command": "uv",
          "args": ["run", "python", "src/mcp_server/server.py"],
          "cwd": "/Users/vishnu/PycharmProjects/IK/python/finnie"
        }
      }
    }

Then restart Claude Desktop β€” Finnie's tools appear in the tool picker.
"""

import re
import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parents[2]))

from mcp.server.fastmcp import FastMCP

from src.agents.goal_agent      import GoalPlanningAgent
from src.agents.market_agent    import MarketAnalysisAgent
from src.agents.news_agent      import NewsSynthesizerAgent
from src.agents.portfolio_agent import PortfolioAnalysisAgent
from src.agents.qa_agent        import FinanceQAAgent
from src.agents.tax_agent       import TaxEducationAgent

mcp = FastMCP("Finnie Finance Assistant")


# ── Lazy agent singletons (loaded once on first call) ─────────────────────────

_agents: dict = {}

def _get(name: str):
    if name not in _agents:
        _agents[name] = {
            "portfolio": PortfolioAnalysisAgent,
            "goal":      GoalPlanningAgent,
            "market":    MarketAnalysisAgent,
            "news":      NewsSynthesizerAgent,
            "tax":       TaxEducationAgent,
            "qa":        FinanceQAAgent,
        }[name]()
    return _agents[name]


# ── Holdings parser (shared by analyze_portfolio) ─────────────────────────────

def _parse_holdings(text: str) -> dict[str, int]:
    holdings: dict[str, int] = {}
    for m in re.finditer(r'\b([A-Z]{1,5})\s*:\s*(\d+(?:\.\d+)?)\b', text.upper()):
        holdings[m.group(1)] = int(float(m.group(2)))
    if holdings:
        return holdings
    for m in re.finditer(r'\b(\d+(?:\.\d+)?)\s+([A-Z]{1,5})\b', text.upper()):
        holdings[m.group(2)] = int(float(m.group(1)))
    return holdings


# ── Tools ─────────────────────────────────────────────────────────────────────

@mcp.tool()
def analyze_portfolio(holdings: str, risk_profile: str = "moderate") -> str:
    """
    Analyze a stock portfolio β€” diversification score, sector allocation,
    asset mix, and personalized recommendations.

    Args:
        holdings:     Holdings in any of these formats:
                        "AAPL: 10, MSFT: 5, BND: 20"
                        "10 AAPL, 5 MSFT, 20 BND"
        risk_profile: conservative | moderate | aggressive  (default: moderate)
    """
    parsed = _parse_holdings(holdings)
    if not parsed:
        return "Could not parse holdings. Use format: AAPL: 10, MSFT: 5, BND: 20"

    result  = _get("portfolio").run(portfolio=parsed, risk_profile=risk_profile)
    metrics = result.get("metrics", {})
    answer  = result.get("answer", "")
    failed  = result.get("failed", [])

    if not metrics:
        return answer

    lines = [
        f"Portfolio Value:       ${metrics.get('total_value', 0):,.2f}",
        f"Positions:             {metrics.get('num_positions', 0)}",
        f"Diversification Score: {metrics.get('diversification_score', 0)} / 10",
        f"Sector Allocation:     {metrics.get('sector_pct', {})}",
        f"Asset Mix:             {metrics.get('asset_pct', {})}",
    ]
    if failed:
        lines.append(f"Failed tickers (no data): {', '.join(failed)}")
    lines += ["", answer]
    return "\n".join(lines)


@mcp.tool()
def plan_financial_goal(
    goal_amount: float,
    time_horizon_years: float,
    current_savings: float = 0.0,
    risk_profile: str = "moderate",
) -> str:
    """
    Calculate how much to save monthly to reach a savings or retirement goal.
    Shows projections with and without investment growth.

    Args:
        goal_amount:         Target amount in dollars (e.g. 2000000 for $2M)
        time_horizon_years:  Years until the goal (e.g. 20)
        current_savings:     Amount already saved, default 0
        risk_profile:        conservative (4%) | moderate (7%) | aggressive (10%)
    """
    result  = _get("goal").run(
        goal_amount=goal_amount,
        time_horizon_years=time_horizon_years,
        current_savings=current_savings,
        risk_profile=risk_profile,
    )
    metrics = result.get("metrics", {})
    answer  = result.get("answer", "")

    if not metrics:
        return answer

    lines = [
        f"Goal:                    ${metrics.get('goal_amount', 0):,.0f}",
        f"Time Horizon:            {metrics.get('time_horizon_years', 0)} years",
        f"Current Savings:         ${metrics.get('current_savings', 0):,.0f}",
        f"Gap:                     ${metrics.get('gap', 0):,.0f}",
        f"Monthly (cash, no growth): ${metrics.get('monthly_no_growth', 0):,.2f}",
        f"Monthly (invested at {metrics.get('annual_return_pct', 7)}%): ${metrics.get('monthly_with_growth', 0):,.2f}",
        f"Projected Value (if invested): ${metrics.get('projected_value', 0):,.0f}",
        "",
        answer,
    ]
    return "\n".join(lines)


@mcp.tool()
def get_stock_data(ticker: str) -> str:
    """
    Get real-time stock price, P/E ratio, market cap, 52-week range,
    and a plain-English company analysis.

    Args:
        ticker: Stock symbol, e.g. AAPL, TSLA, NVDA, SPY
    """
    result = _get("market").run(f"Tell me about {ticker} stock")
    return result.get("answer", f"Could not fetch data for {ticker}.")


@mcp.tool()
def get_financial_news(query: str) -> str:
    """
    Fetch and summarize recent financial news for one or more stock tickers.
    Each headline is tagged bullish / bearish / neutral.

    Args:
        query: Natural language query mentioning tickers,
               e.g. "latest news on NVDA and MSFT"
    """
    result    = _get("news").run(query)
    headlines = result.get("headlines", [])
    answer    = result.get("answer", "")
    error     = result.get("error")

    if error:
        return answer

    header = "Headlines:\n" + "\n".join(
        f"  [{h['sentiment']:8s}] [{h['ticker']}] {h['title']}"
        for h in headlines[:8]
    )
    return header + "\n\n" + answer


@mcp.tool()
def get_tax_education(query: str) -> str:
    """
    Explain US tax concepts related to investing.
    Handles: capital gains tax, IRA / Roth IRA / 401k / HSA contribution limits,
    tax-loss harvesting, and general tax questions.

    Args:
        query: Tax question, e.g.
               "I sold AAPL after 8 months with a $5,000 gain β€” 22% bracket"
               "How much can I contribute to my Roth IRA?"
               "I have a $4,000 loss this year, can I harvest it?"
    """
    result   = _get("tax").run(query)
    metrics  = result.get("metrics", {})
    answer   = result.get("answer", "")
    scenario = result.get("scenario", "")

    if not metrics:
        return answer

    if scenario == "capital_gains":
        header = (
            f"Gain: ${metrics.get('gain', 0):,.2f}  |  "
            f"Type: {metrics.get('holding_type', '')}  |  "
            f"Rate: {metrics.get('tax_rate_pct', 0)}%  |  "
            f"Est. tax: ${metrics.get('estimated_tax', 0):,.2f}  |  "
            f"Net gain: ${metrics.get('net_gain', 0):,.2f}"
        )
        return header + "\n\n" + answer

    if scenario == "tax_loss":
        header = (
            f"Loss: ${metrics.get('total_loss', 0):,.2f}  |  "
            f"Deductible this year: ${metrics.get('deductible_this_year', 0):,.2f}  |  "
            f"Carryforward: ${metrics.get('carryforward_to_next', 0):,.2f}  |  "
            f"Tax saving: ${metrics.get('estimated_tax_saving', 0):,.2f}"
        )
        return header + "\n\n" + answer

    return answer


@mcp.tool()
def answer_finance_question(query: str) -> str:
    """
    Answer a general financial education question using a curated knowledge base.
    Use for: what is X, how does Y work, ETFs vs mutual funds, compound interest,
    diversification, dollar-cost averaging, Sharpe ratio, bond basics, etc.

    Args:
        query: Any general finance or investing question
    """
    result = _get("qa").run(query)
    return result.get("answer", "")


# ── Entry point ───────────────────────────────────────────────────────────────

if __name__ == "__main__":
    mcp.run()