# FinAgent MCP Server Implementation Plan

> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

**Goal:** Build a freemium financial analysis MCP server (4 compound tools) powered by free APIs (yfinance + SEC EDGAR), packaged for the MCP Marketplace.

**Architecture:** FastMCP server exposing 4 tools. Services layer wraps data sources. File-based JSON cache for immutable data. Stdio + Streamable HTTP transports. License key gating on paid tools.

**Tech Stack:** Python 3.14, FastMCP (mcp SDK), yfinance, requests, BeautifulSoup4, pytest

---

## Phase 1: Project Scaffold

### Task 1: Initialize project structure and dependencies

**Files:**
- Create: `pyproject.toml`
- Create: `src/finagent/__init__.py`
- Create: `src/finagent/server.py`
- Create: `.gitignore`
- Create: `.env.example`

**Step 1: Create pyproject.toml**

```toml
[project]
name = "finagent"
version = "0.1.0"
description = "Financial analysis MCP server — stock data, SEC filings, market news, and screening"
readme = "README.md"
requires-python = ">=3.11"
license = { text = "MIT" }
dependencies = [
    "mcp[cli]>=1.0.0",
    "yfinance>=0.2.40",
    "requests>=2.31.0",
    "beautifulsoup4>=4.12.0",
    "lxml>=5.0.0",
]

[project.scripts]
finagent = "finagent.server:main"

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[tool.hatch.build.targets.wheel]
packages = ["src/finagent"]

[project.optional-dependencies]
dev = [
    "pytest>=8.0.0",
    "pytest-asyncio>=0.23.0",
]
```

**Step 2: Create src/finagent/__init__.py**

```python
"""FinAgent — Financial analysis MCP server."""
```

**Step 3: Create minimal server.py with no tools**

```python
from mcp.server.fastmcp import FastMCP

mcp = FastMCP(
    "finagent",
    description="Financial analysis MCP server: stock data, SEC filings, market news, and screening.",
)


def main():
    """Entry point for stdio transport."""
    mcp.run()


if __name__ == "__main__":
    main()
```

**Step 4: Create .gitignore**

```
__pycache__/
*.pyc
.env
cache/
.finagent/
dist/
*.egg-info/
.venv/
```

**Step 5: Create .env.example**

```bash
# Optional: License key for paid features (sec_filings, stock_screener)
# Get one at mcp-marketplace.io/server/finagent
FINAGENT_LICENSE_KEY=

# Optional: Contact email for SEC EDGAR API (recommended)
FINAGENT_CONTACT_EMAIL=your.email@example.com
```

**Step 6: Initialize git repo, create venv, install deps**

```bash
cd ~/Desktop/AI\ Projects/MCPs/finagent
git init
python -m venv .venv
source .venv/bin/activate
pip install -e ".[dev]"
```

**Step 7: Verify server starts**

```bash
python -c "from finagent.server import mcp; print('Server OK:', mcp.name)"
```

Expected: `Server OK: finagent`

**Step 8: Commit**

```bash
git add pyproject.toml src/ .gitignore .env.example
git commit -m "feat: initialize finagent MCP server project"
```

---

## Phase 2: Cache Layer

Build the cache first since services depend on it.

### Task 2: Build file-based JSON cache

**Files:**
- Create: `src/finagent/cache.py`
- Create: `tests/test_cache.py`

**Step 1: Write failing tests**

```python
# tests/test_cache.py
import json
import time
from pathlib import Path

from finagent.cache import FileCache


def test_cache_set_and_get(tmp_path):
    cache = FileCache(cache_dir=tmp_path)
    cache.set("test_key", {"price": 150.0})
    result = cache.get("test_key")
    assert result == {"price": 150.0}


def test_cache_miss_returns_none(tmp_path):
    cache = FileCache(cache_dir=tmp_path)
    result = cache.get("nonexistent")
    assert result is None


def test_cache_expiry(tmp_path):
    cache = FileCache(cache_dir=tmp_path)
    cache.set("expiring", {"data": 1}, ttl_seconds=1)
    assert cache.get("expiring") == {"data": 1}
    time.sleep(1.1)
    assert cache.get("expiring") is None


def test_cache_no_expiry(tmp_path):
    cache = FileCache(cache_dir=tmp_path)
    cache.set("permanent", {"data": 1}, ttl_seconds=None)
    result = cache.get("permanent")
    assert result == {"data": 1}


def test_cache_files_are_readable_json(tmp_path):
    cache = FileCache(cache_dir=tmp_path)
    cache.set("readable", {"ticker": "AAPL", "price": 150.0})
    cache_files = list(tmp_path.glob("*.json"))
    assert len(cache_files) == 1
    with open(cache_files[0]) as f:
        data = json.load(f)
    assert data["value"]["ticker"] == "AAPL"
```

**Step 2: Run tests to verify they fail**

```bash
cd ~/Desktop/AI\ Projects/MCPs/finagent
pytest tests/test_cache.py -v
```

Expected: FAIL — `ModuleNotFoundError: No module named 'finagent.cache'`

**Step 3: Implement cache**

```python
# src/finagent/cache.py
"""File-based JSON cache. Human-readable, debuggable."""

import hashlib
import json
import time
from pathlib import Path


class FileCache:
    """Simple file-based cache storing JSON files.

    Cache files are human-readable for easy debugging.
    Each entry stores: value, created_at, ttl_seconds.
    """

    def __init__(self, cache_dir: Path | str | None = None):
        if cache_dir is None:
            cache_dir = Path.home() / ".finagent" / "cache"
        self.cache_dir = Path(cache_dir)
        self.cache_dir.mkdir(parents=True, exist_ok=True)

    def _key_to_path(self, key: str) -> Path:
        safe_name = hashlib.sha256(key.encode()).hexdigest()[:16]
        # Also include a readable prefix from the key
        readable = key.replace("/", "_").replace(":", "_")[:40]
        return self.cache_dir / f"{readable}_{safe_name}.json"

    def get(self, key: str) -> dict | list | None:
        path = self._key_to_path(key)
        if not path.exists():
            return None

        with open(path) as f:
            entry = json.load(f)

        ttl = entry.get("ttl_seconds")
        if ttl is not None:
            elapsed = time.time() - entry["created_at"]
            if elapsed > ttl:
                path.unlink(missing_ok=True)
                return None

        return entry["value"]

    def set(self, key: str, value: dict | list, ttl_seconds: int | None = 3600) -> None:
        entry = {
            "key": key,
            "value": value,
            "created_at": time.time(),
            "ttl_seconds": ttl_seconds,
        }
        path = self._key_to_path(key)
        with open(path, "w") as f:
            json.dump(entry, f, indent=2, default=str)
```

**Step 4: Run tests**

```bash
pytest tests/test_cache.py -v
```

Expected: All PASS

**Step 5: Commit**

```bash
git add src/finagent/cache.py tests/test_cache.py
git commit -m "feat: add file-based JSON cache layer"
```

---

## Phase 3: Market Data Service (yfinance)

### Task 3: Build yfinance wrapper service

**Files:**
- Create: `src/finagent/services/__init__.py`
- Create: `src/finagent/services/market_data.py`
- Create: `tests/test_market_data.py`

**Step 1: Write failing tests**

```python
# tests/test_market_data.py
"""Tests for market data service.

These tests call the real yfinance API (no mocking).
Use a well-known ticker (AAPL) for reliability.
"""

import pytest
from finagent.services.market_data import MarketDataService


@pytest.fixture
def service(tmp_path):
    return MarketDataService(cache_dir=tmp_path)


def test_get_quote(service):
    result = service.get_quote("AAPL")
    assert result["ticker"] == "AAPL"
    assert "price" in result
    assert "market_cap" in result
    assert isinstance(result["price"], (int, float))


def test_get_income_statement(service):
    result = service.get_income_statement("AAPL", period="annual", limit=2)
    assert isinstance(result, list)
    assert len(result) <= 2
    assert "total_revenue" in result[0]


def test_get_balance_sheet(service):
    result = service.get_balance_sheet("AAPL", period="annual", limit=2)
    assert isinstance(result, list)
    assert len(result) <= 2
    assert "total_assets" in result[0]


def test_get_cash_flow(service):
    result = service.get_cash_flow("AAPL", period="annual", limit=2)
    assert isinstance(result, list)
    assert len(result) <= 2


def test_get_analyst_estimates(service):
    result = service.get_analyst_estimates("AAPL")
    assert isinstance(result, dict)
    # Should have recommendations or price targets
    assert "recommendations" in result or "price_targets" in result


def test_get_insider_trades(service):
    result = service.get_insider_trades("AAPL")
    assert isinstance(result, list)


def test_get_key_ratios(service):
    result = service.get_key_ratios("AAPL")
    assert isinstance(result, dict)
    assert "ticker" in result


def test_invalid_ticker(service):
    result = service.get_quote("XYZNOTREAL123")
    assert "error" in result
```

**Step 2: Run tests to verify they fail**

```bash
pytest tests/test_market_data.py -v
```

Expected: FAIL — `ModuleNotFoundError`

**Step 3: Implement market data service**

```python
# src/finagent/services/__init__.py
"""FinAgent services — data retrieval layer."""
```

```python
# src/finagent/services/market_data.py
"""Market data service wrapping yfinance."""

from pathlib import Path

import yfinance as yf

from finagent.cache import FileCache


class MarketDataService:
    def __init__(self, cache_dir: Path | str | None = None):
        self.cache = FileCache(cache_dir=cache_dir)

    def _get_ticker(self, symbol: str) -> yf.Ticker:
        return yf.Ticker(symbol)

    def get_quote(self, ticker: str) -> dict:
        try:
            t = self._get_ticker(ticker)
            info = t.info
            if not info or info.get("regularMarketPrice") is None:
                return {"error": "invalid_ticker", "message": f"No data found for ticker '{ticker}'"}
            return {
                "ticker": ticker.upper(),
                "price": info.get("regularMarketPrice") or info.get("currentPrice"),
                "previous_close": info.get("previousClose"),
                "open": info.get("regularMarketOpen"),
                "day_high": info.get("regularMarketDayHigh"),
                "day_low": info.get("regularMarketDayLow"),
                "volume": info.get("regularMarketVolume"),
                "market_cap": info.get("marketCap"),
                "pe_ratio": info.get("trailingPE"),
                "forward_pe": info.get("forwardPE"),
                "dividend_yield": info.get("dividendYield"),
                "52_week_high": info.get("fiftyTwoWeekHigh"),
                "52_week_low": info.get("fiftyTwoWeekLow"),
                "currency": info.get("currency"),
                "exchange": info.get("exchange"),
                "name": info.get("longName") or info.get("shortName"),
            }
        except Exception as e:
            return {"error": "invalid_ticker", "message": f"Failed to fetch data for '{ticker}': {str(e)}"}

    def _df_to_records(self, df, limit: int) -> list[dict]:
        """Convert a yfinance DataFrame (columns=dates) to list of dicts."""
        if df is None or df.empty:
            return []
        # yfinance returns columns as dates, rows as metrics — transpose
        records = []
        for col in list(df.columns)[:limit]:
            record = {"period": str(col.date()) if hasattr(col, "date") else str(col)}
            for idx in df.index:
                key = str(idx).lower().replace(" ", "_")
                val = df.loc[idx, col]
                record[key] = None if (val != val) else val  # NaN check
            records.append(record)
        return records

    def get_income_statement(self, ticker: str, period: str = "annual", limit: int = 4) -> list[dict]:
        cache_key = f"income_{ticker}_{period}_{limit}"
        cached = self.cache.get(cache_key)
        if cached:
            return cached

        t = self._get_ticker(ticker)
        df = t.quarterly_income_stmt if period == "quarterly" else t.income_stmt
        result = self._df_to_records(df, limit)

        if result:
            self.cache.set(cache_key, result, ttl_seconds=86400)
        return result

    def get_balance_sheet(self, ticker: str, period: str = "annual", limit: int = 4) -> list[dict]:
        cache_key = f"balance_{ticker}_{period}_{limit}"
        cached = self.cache.get(cache_key)
        if cached:
            return cached

        t = self._get_ticker(ticker)
        df = t.quarterly_balance_sheet if period == "quarterly" else t.balance_sheet
        result = self._df_to_records(df, limit)

        if result:
            self.cache.set(cache_key, result, ttl_seconds=86400)
        return result

    def get_cash_flow(self, ticker: str, period: str = "annual", limit: int = 4) -> list[dict]:
        cache_key = f"cashflow_{ticker}_{period}_{limit}"
        cached = self.cache.get(cache_key)
        if cached:
            return cached

        t = self._get_ticker(ticker)
        df = t.quarterly_cashflow if period == "quarterly" else t.cashflow
        result = self._df_to_records(df, limit)

        if result:
            self.cache.set(cache_key, result, ttl_seconds=86400)
        return result

    def get_analyst_estimates(self, ticker: str) -> dict:
        cache_key = f"analyst_{ticker}"
        cached = self.cache.get(cache_key)
        if cached:
            return cached

        t = self._get_ticker(ticker)
        result = {"ticker": ticker.upper()}

        # Recommendations
        recs = t.recommendations
        if recs is not None and not recs.empty:
            result["recommendations"] = recs.head(20).reset_index().to_dict(orient="records")

        # Price targets
        targets = t.analyst_price_targets
        if targets is not None:
            if hasattr(targets, "to_dict"):
                result["price_targets"] = targets.to_dict()
            else:
                result["price_targets"] = targets

        self.cache.set(cache_key, result, ttl_seconds=3600)
        return result

    def get_insider_trades(self, ticker: str) -> list[dict]:
        cache_key = f"insider_{ticker}"
        cached = self.cache.get(cache_key)
        if cached:
            return cached

        t = self._get_ticker(ticker)
        trades = t.insider_transactions
        if trades is None or trades.empty:
            return []

        result = trades.head(50).to_dict(orient="records")
        self.cache.set(cache_key, result, ttl_seconds=21600)
        return result

    def get_key_ratios(self, ticker: str) -> dict:
        t = self._get_ticker(ticker)
        info = t.info
        if not info:
            return {"error": "invalid_ticker", "message": f"No data for '{ticker}'"}

        return {
            "ticker": ticker.upper(),
            "pe_ratio": info.get("trailingPE"),
            "forward_pe": info.get("forwardPE"),
            "peg_ratio": info.get("pegRatio"),
            "price_to_book": info.get("priceToBook"),
            "price_to_sales": info.get("priceToSalesTrailing12Months"),
            "ev_to_ebitda": info.get("enterpriseToEbitda"),
            "ev_to_revenue": info.get("enterpriseToRevenue"),
            "profit_margin": info.get("profitMargins"),
            "operating_margin": info.get("operatingMargins"),
            "return_on_equity": info.get("returnOnEquity"),
            "return_on_assets": info.get("returnOnAssets"),
            "debt_to_equity": info.get("debtToEquity"),
            "current_ratio": info.get("currentRatio"),
            "quick_ratio": info.get("quickRatio"),
            "revenue_growth": info.get("revenueGrowth"),
            "earnings_growth": info.get("earningsGrowth"),
            "dividend_yield": info.get("dividendYield"),
            "payout_ratio": info.get("payoutRatio"),
            "beta": info.get("beta"),
        }
```

**Step 4: Run tests**

```bash
pytest tests/test_market_data.py -v --timeout=30
```

Expected: All PASS (tests hit real API, may take 10-15 seconds)

**Step 5: Commit**

```bash
git add src/finagent/services/ tests/test_market_data.py
git commit -m "feat: add market data service (yfinance wrapper)"
```

---

## Phase 4: financial_data MCP Tool

### Task 4: Register financial_data tool on the server

**Files:**
- Create: `src/finagent/tools/__init__.py`
- Create: `src/finagent/tools/financial_data.py`
- Modify: `src/finagent/server.py`
- Create: `tests/test_financial_data_tool.py`

**Step 1: Write failing test**

```python
# tests/test_financial_data_tool.py
"""Test the financial_data MCP tool."""

import json
import pytest
from finagent.tools.financial_data import financial_data


def test_financial_data_quote():
    result = financial_data(ticker="AAPL", data_type="quote")
    data = json.loads(result)
    assert data["ticker"] == "AAPL"
    assert "price" in data


def test_financial_data_income_statement():
    result = financial_data(ticker="AAPL", data_type="income_statement", period="annual", limit=2)
    data = json.loads(result)
    assert isinstance(data, list)
    assert len(data) <= 2


def test_financial_data_invalid_type():
    result = financial_data(ticker="AAPL", data_type="invalid_thing")
    data = json.loads(result)
    assert "error" in data


def test_financial_data_invalid_ticker():
    result = financial_data(ticker="XYZNOTREAL123", data_type="quote")
    data = json.loads(result)
    assert "error" in data
```

**Step 2: Run test to verify it fails**

```bash
pytest tests/test_financial_data_tool.py -v
```

Expected: FAIL — `ModuleNotFoundError`

**Step 3: Implement tool**

```python
# src/finagent/tools/__init__.py
"""FinAgent MCP tools."""
```

```python
# src/finagent/tools/financial_data.py
"""financial_data MCP tool — compound tool for all market data."""

import json

from finagent.services.market_data import MarketDataService

_service = MarketDataService()

VALID_DATA_TYPES = [
    "quote",
    "income_statement",
    "balance_sheet",
    "cash_flow",
    "analyst_estimates",
    "insider_trades",
    "key_ratios",
]


def financial_data(
    ticker: str,
    data_type: str,
    period: str = "annual",
    limit: int = 4,
) -> str:
    """Get financial data for any public stock: quotes, income statements,
    balance sheets, cash flow, analyst estimates, insider trades, and key ratios.

    Args:
        ticker: Stock symbol (e.g., NVDA, AAPL, MSFT)
        data_type: Type of data. One of: quote, income_statement, balance_sheet,
                   cash_flow, analyst_estimates, insider_trades, key_ratios
        period: 'quarterly' or 'annual' (for financial statements). Default: annual
        limit: Number of periods to return (for financial statements). Default: 4
    """
    ticker = ticker.upper().strip()

    if data_type not in VALID_DATA_TYPES:
        return json.dumps({
            "error": "invalid_data_type",
            "message": f"data_type must be one of: {', '.join(VALID_DATA_TYPES)}",
        })

    dispatch = {
        "quote": lambda: _service.get_quote(ticker),
        "income_statement": lambda: _service.get_income_statement(ticker, period, limit),
        "balance_sheet": lambda: _service.get_balance_sheet(ticker, period, limit),
        "cash_flow": lambda: _service.get_cash_flow(ticker, period, limit),
        "analyst_estimates": lambda: _service.get_analyst_estimates(ticker),
        "insider_trades": lambda: _service.get_insider_trades(ticker),
        "key_ratios": lambda: _service.get_key_ratios(ticker),
    }

    result = dispatch[data_type]()
    return json.dumps(result, default=str)
```

**Step 4: Register tool on server**

Update `src/finagent/server.py`:

```python
from mcp.server.fastmcp import FastMCP

from finagent.tools.financial_data import financial_data as _financial_data

mcp = FastMCP(
    "finagent",
    description="Financial analysis MCP server: stock data, SEC filings, market news, and screening.",
)


@mcp.tool()
def financial_data(
    ticker: str,
    data_type: str,
    period: str = "annual",
    limit: int = 4,
) -> str:
    """Get financial data for any public stock: quotes, income statements,
    balance sheets, cash flow, analyst estimates, insider trades, and key ratios.

    Args:
        ticker: Stock symbol (e.g., NVDA, AAPL, MSFT)
        data_type: Type of data. One of: quote, income_statement, balance_sheet,
                   cash_flow, analyst_estimates, insider_trades, key_ratios
        period: 'quarterly' or 'annual' (for financial statements). Default: annual
        limit: Number of periods to return (for financial statements). Default: 4
    """
    return _financial_data(ticker=ticker, data_type=data_type, period=period, limit=limit)


def main():
    """Entry point for stdio transport."""
    mcp.run()


if __name__ == "__main__":
    main()
```

**Step 5: Run tests**

```bash
pytest tests/test_financial_data_tool.py -v --timeout=30
```

Expected: All PASS

**Step 6: Commit**

```bash
git add src/finagent/tools/ src/finagent/server.py tests/test_financial_data_tool.py
git commit -m "feat: add financial_data MCP tool"
```

---

## Phase 5: News Service

### Task 5: Build news aggregation service

**Files:**
- Create: `src/finagent/services/news.py`
- Create: `tests/test_news.py`

**Step 1: Write failing tests**

```python
# tests/test_news.py
"""Tests for news service.

Uses yfinance news (free, no API key).
"""

import pytest
from finagent.services.news import NewsService


@pytest.fixture
def service():
    return NewsService()


def test_get_news_for_ticker(service):
    result = service.get_news(ticker="AAPL")
    assert isinstance(result, list)
    if result:  # yfinance news may be empty depending on timing
        article = result[0]
        assert "title" in article


def test_get_news_with_query(service):
    result = service.get_news(query="earnings report")
    assert isinstance(result, list)
```

**Step 2: Run test to verify it fails**

```bash
pytest tests/test_news.py -v
```

**Step 3: Implement news service**

```python
# src/finagent/services/news.py
"""News service — financial news aggregation via yfinance."""

import yfinance as yf


class NewsService:
    def get_news(self, ticker: str | None = None, query: str | None = None, days_back: int = 7) -> list[dict]:
        """Get financial news. Uses yfinance news feed.

        Args:
            ticker: Stock symbol to get news for
            query: Search query (used for filtering if ticker also provided)
            days_back: How far back to search (yfinance returns recent news regardless)
        """
        if not ticker and not query:
            return []

        articles = []

        if ticker:
            try:
                t = yf.Ticker(ticker.upper())
                news = t.news
                if news:
                    for item in news:
                        article = {
                            "title": item.get("title", ""),
                            "source": item.get("publisher", ""),
                            "link": item.get("link", ""),
                            "published": item.get("providerPublishTime", ""),
                            "type": item.get("type", ""),
                            "related_tickers": item.get("relatedTickers", []),
                        }
                        # If query provided, filter articles by keyword
                        if query and query.lower() not in article["title"].lower():
                            continue
                        articles.append(article)
            except Exception:
                pass

        # If only query provided (no ticker), search a few major index tickers
        if not ticker and query:
            for sym in ["SPY", "QQQ", "DIA"]:
                try:
                    t = yf.Ticker(sym)
                    news = t.news
                    if news:
                        for item in news:
                            title = item.get("title", "")
                            if query.lower() in title.lower():
                                articles.append({
                                    "title": title,
                                    "source": item.get("publisher", ""),
                                    "link": item.get("link", ""),
                                    "published": item.get("providerPublishTime", ""),
                                    "type": item.get("type", ""),
                                    "related_tickers": item.get("relatedTickers", []),
                                })
                except Exception:
                    continue

        return articles
```

**Step 4: Run tests**

```bash
pytest tests/test_news.py -v --timeout=30
```

Expected: PASS

**Step 5: Commit**

```bash
git add src/finagent/services/news.py tests/test_news.py
git commit -m "feat: add news aggregation service"
```

---

## Phase 6: market_news MCP Tool

### Task 6: Register market_news tool

**Files:**
- Create: `src/finagent/tools/market_news.py`
- Modify: `src/finagent/server.py`
- Create: `tests/test_market_news_tool.py`

**Step 1: Write failing test**

```python
# tests/test_market_news_tool.py
import json
from finagent.tools.market_news import market_news


def test_market_news_with_ticker():
    result = market_news(query="earnings", ticker="AAPL")
    data = json.loads(result)
    assert isinstance(data, list)


def test_market_news_query_only():
    result = market_news(query="Federal Reserve rate decision")
    data = json.loads(result)
    assert isinstance(data, list)
```

**Step 2: Run to verify fail**

```bash
pytest tests/test_market_news_tool.py -v
```

**Step 3: Implement tool**

```python
# src/finagent/tools/market_news.py
"""market_news MCP tool."""

import json

from finagent.services.news import NewsService

_service = NewsService()


def market_news(
    query: str,
    ticker: str | None = None,
    days_back: int = 7,
) -> str:
    """Search for financial news, analyst reactions, and market sentiment
    about a stock or market topic.

    Args:
        query: Search query (e.g., 'NVDA earnings reaction', 'Fed rate decision')
        ticker: Optional stock symbol to filter results to a specific stock
        days_back: How far back to search. Default: 7 days
    """
    result = _service.get_news(ticker=ticker, query=query, days_back=days_back)
    return json.dumps(result, default=str)
```

**Step 4: Add to server.py**

Add import and tool registration to `src/finagent/server.py`:

```python
from finagent.tools.market_news import market_news as _market_news

@mcp.tool()
def market_news(
    query: str,
    ticker: str | None = None,
    days_back: int = 7,
) -> str:
    """Search for financial news, analyst reactions, and market sentiment
    about a stock or market topic.

    Args:
        query: Search query (e.g., 'NVDA earnings reaction', 'Fed rate decision')
        ticker: Optional stock symbol to filter results to a specific stock
        days_back: How far back to search. Default: 7 days
    """
    return _market_news(query=query, ticker=ticker, days_back=days_back)
```

**Step 5: Run tests**

```bash
pytest tests/test_market_news_tool.py -v --timeout=30
```

Expected: PASS

**Step 6: Commit**

```bash
git add src/finagent/tools/market_news.py src/finagent/server.py tests/test_market_news_tool.py
git commit -m "feat: add market_news MCP tool"
```

---

## Phase 7: SEC EDGAR Service

### Task 7: Build SEC EDGAR filing service

**Files:**
- Create: `src/finagent/services/sec_edgar.py`
- Create: `tests/test_sec_edgar.py`

**Step 1: Write failing tests**

```python
# tests/test_sec_edgar.py
"""Tests for SEC EDGAR service.

Hits real SEC API — be mindful of rate limits (10 req/s max).
"""

import time
import pytest
from finagent.services.sec_edgar import SECEdgarService


@pytest.fixture
def service(tmp_path):
    return SECEdgarService(
        contact_email="test@finagent.dev",
        cache_dir=tmp_path,
    )


def test_get_cik(service):
    cik = service.get_cik("AAPL")
    assert cik == "0000320193"


def test_get_cik_invalid(service):
    cik = service.get_cik("XYZNOTREAL123")
    assert cik is None


def test_list_filings(service):
    filings = service.list_filings("AAPL", form_types=["10-K"], limit=3)
    assert isinstance(filings, list)
    assert len(filings) <= 3
    assert filings[0]["form"] == "10-K"
    assert "filing_date" in filings[0]
    assert "accession_number" in filings[0]


def test_get_filing_metadata(service):
    time.sleep(0.2)  # Rate limit courtesy
    filings = service.list_filings("AAPL", form_types=["10-K"], limit=1)
    assert len(filings) > 0
    # Just verify we can list filings — full text extraction tested separately


def test_extract_section(service):
    time.sleep(0.2)
    result = service.get_filing_section(
        ticker="AAPL",
        filing_type="10-K",
        section="risk_factors",
    )
    assert isinstance(result, dict)
    assert "content" in result or "error" in result
    if "content" in result:
        assert len(result["content"]) > 100  # Risk factors should be substantial
```

**Step 2: Run to verify fail**

```bash
pytest tests/test_sec_edgar.py -v
```

**Step 3: Implement SEC EDGAR service**

```python
# src/finagent/services/sec_edgar.py
"""SEC EDGAR service — filing lookup and section extraction."""

import re
import time
from pathlib import Path

import requests
from bs4 import BeautifulSoup

from finagent.cache import FileCache


# Section regex patterns for 10-K filings
SECTION_PATTERNS = {
    "business_overview": {
        "start": re.compile(r"item\s*1[\.\;\:\-\_\s]+business", re.IGNORECASE),
        "end": re.compile(r"item\s*1a|item\s*2", re.IGNORECASE),
    },
    "risk_factors": {
        "start": re.compile(r"item\s*1a[\.\;\:\-\_\s]+risk\s*factors", re.IGNORECASE),
        "end": re.compile(r"item\s*1b|item\s*2", re.IGNORECASE),
    },
    "item_7": {
        "start": re.compile(
            r"item\s*7[\.\;\:\-\_\s]*management.{0,5}s?\s*discussion",
            re.IGNORECASE,
        ),
        "end": re.compile(r"item\s*7a|item\s*8", re.IGNORECASE),
    },
    "item_7a": {
        "start": re.compile(r"item\s*7a[\.\;\:\-\_\s]*quanti", re.IGNORECASE),
        "end": re.compile(r"item\s*8", re.IGNORECASE),
    },
    "financial_statements": {
        "start": re.compile(r"item\s*8[\.\;\:\-\_\s]*financial\s*statements", re.IGNORECASE),
        "end": re.compile(r"item\s*9", re.IGNORECASE),
    },
}


class SECEdgarService:
    BASE_URL = "https://data.sec.gov"
    ARCHIVES_URL = "https://www.sec.gov/Archives/edgar/data"

    def __init__(self, contact_email: str = "finagent@example.com", cache_dir: Path | str | None = None):
        self.headers = {
            "User-Agent": f"FinAgent {contact_email}",
            "Accept-Encoding": "gzip, deflate",
        }
        self.cache = FileCache(cache_dir=cache_dir)
        self._ticker_map: dict[str, str] | None = None
        self._last_request = 0.0

    def _rate_limit(self):
        """Ensure at least 0.15s between requests (well under 10/s limit)."""
        elapsed = time.time() - self._last_request
        if elapsed < 0.15:
            time.sleep(0.15 - elapsed)
        self._last_request = time.time()

    def _load_ticker_map(self) -> dict[str, str]:
        if self._ticker_map is not None:
            return self._ticker_map

        cached = self.cache.get("sec_ticker_map")
        if cached:
            self._ticker_map = cached
            return cached

        self._rate_limit()
        resp = requests.get(
            "https://www.sec.gov/files/company_tickers.json",
            headers=self.headers,
        )
        resp.raise_for_status()

        ticker_map = {}
        for entry in resp.json().values():
            ticker_map[entry["ticker"].upper()] = str(entry["cik_str"]).zfill(10)

        self.cache.set("sec_ticker_map", ticker_map, ttl_seconds=86400)
        self._ticker_map = ticker_map
        return ticker_map

    def get_cik(self, ticker: str) -> str | None:
        ticker_map = self._load_ticker_map()
        return ticker_map.get(ticker.upper())

    def list_filings(
        self,
        ticker: str,
        form_types: list[str] | None = None,
        limit: int = 10,
    ) -> list[dict]:
        cik = self.get_cik(ticker)
        if not cik:
            return []

        cache_key = f"filings_{cik}"
        cached = self.cache.get(cache_key)

        if not cached:
            self._rate_limit()
            url = f"{self.BASE_URL}/submissions/CIK{cik}.json"
            resp = requests.get(url, headers=self.headers)
            resp.raise_for_status()
            cached = resp.json()
            self.cache.set(cache_key, cached, ttl_seconds=86400)

        recent = cached.get("filings", {}).get("recent", {})
        if not recent:
            return []

        filings = []
        for i in range(len(recent.get("accessionNumber", []))):
            form = recent["form"][i]
            if form_types and form not in form_types:
                continue
            filings.append({
                "accession_number": recent["accessionNumber"][i],
                "form": form,
                "filing_date": recent["filingDate"][i],
                "report_date": recent.get("reportDate", [""])[i],
                "primary_document": recent["primaryDocument"][i],
            })
            if len(filings) >= limit:
                break

        return filings

    def _download_filing(self, cik: str, accession_number: str, primary_document: str) -> str:
        cache_key = f"filing_doc_{accession_number}_{primary_document}"
        cached = self.cache.get(cache_key)
        if cached:
            return cached["html"]

        acc_clean = accession_number.replace("-", "")
        cik_clean = cik.lstrip("0")
        url = f"{self.ARCHIVES_URL}/{cik_clean}/{acc_clean}/{primary_document}"

        self._rate_limit()
        resp = requests.get(url, headers=self.headers)
        resp.raise_for_status()

        # Cache forever — SEC filings are immutable
        self.cache.set(cache_key, {"html": resp.text}, ttl_seconds=None)
        return resp.text

    def _extract_section(self, html: str, section: str) -> str:
        patterns = SECTION_PATTERNS.get(section)
        if not patterns:
            return ""

        soup = BeautifulSoup(html, "lxml")
        text = soup.get_text(separator="\n")
        text = re.sub(r"\xa0", " ", text)
        text = re.sub(r"\s+", " ", text)

        starts = list(patterns["start"].finditer(text))
        ends = list(patterns["end"].finditer(text))

        if not starts or not ends:
            return ""

        best = ""
        for s in starts:
            for e in ends:
                if s.end() < e.start():
                    candidate = text[s.end():e.start()].strip()
                    if len(candidate) > len(best):
                        best = candidate

        return best

    def get_filing_section(
        self,
        ticker: str,
        filing_type: str = "10-K",
        section: str | None = None,
        date_range: str | None = None,
    ) -> dict:
        cik = self.get_cik(ticker)
        if not cik:
            return {"error": "invalid_ticker", "message": f"Could not find CIK for '{ticker}'"}

        filings = self.list_filings(ticker, form_types=[filing_type], limit=5)
        if not filings:
            return {"error": "no_filings", "message": f"No {filing_type} filings found for '{ticker}'"}

        # Filter by date range if provided
        target = filings[0]  # Default: most recent
        if date_range:
            for f in filings:
                if date_range in f["filing_date"]:
                    target = f
                    break

        if not section:
            return {
                "ticker": ticker.upper(),
                "filing_type": filing_type,
                "filing_date": target["filing_date"],
                "available_sections": list(SECTION_PATTERNS.keys()),
                "message": "Specify a section to extract content. Available: " + ", ".join(SECTION_PATTERNS.keys()),
            }

        html = self._download_filing(cik, target["accession_number"], target["primary_document"])
        content = self._extract_section(html, section)

        if not content:
            return {
                "error": "section_not_found",
                "message": f"Could not extract '{section}' from {filing_type} filed {target['filing_date']}",
                "available_sections": list(SECTION_PATTERNS.keys()),
            }

        # Truncate if very long (some sections are 50k+ chars)
        if len(content) > 30000:
            content = content[:30000] + "\n\n[Truncated — section exceeds 30,000 characters]"

        return {
            "ticker": ticker.upper(),
            "filing_type": filing_type,
            "filing_date": target["filing_date"],
            "section": section,
            "content": content,
        }
```

**Step 4: Run tests**

```bash
pytest tests/test_sec_edgar.py -v --timeout=60
```

Expected: All PASS (SEC API calls may be slow)

**Step 5: Commit**

```bash
git add src/finagent/services/sec_edgar.py tests/test_sec_edgar.py
git commit -m "feat: add SEC EDGAR filing service"
```

---

## Phase 8: sec_filings MCP Tool (Paid)

### Task 8: Register sec_filings tool with license gating

**Files:**
- Create: `src/finagent/license.py`
- Create: `src/finagent/tools/sec_filings.py`
- Modify: `src/finagent/server.py`
- Create: `tests/test_license.py`
- Create: `tests/test_sec_filings_tool.py`

**Step 1: Write license gate tests**

```python
# tests/test_license.py
import os
import pytest
from finagent.license import check_license


def test_no_license_key():
    os.environ.pop("FINAGENT_LICENSE_KEY", None)
    result = check_license()
    assert result["valid"] is False


def test_license_key_present(monkeypatch):
    monkeypatch.setenv("FINAGENT_LICENSE_KEY", "test-key-123")
    result = check_license()
    # In dev/test mode, any non-empty key is accepted
    assert result["valid"] is True
```

**Step 2: Implement license check**

```python
# src/finagent/license.py
"""License key validation for paid tools."""

import os


def check_license() -> dict:
    """Check if a valid license key is configured.

    For MVP: accepts any non-empty FINAGENT_LICENSE_KEY env var.
    Future: validates against marketplace API.
    """
    key = os.environ.get("FINAGENT_LICENSE_KEY", "").strip()

    if not key:
        return {
            "valid": False,
            "message": "No license key found. Set FINAGENT_LICENSE_KEY environment variable.",
        }

    # TODO: Validate against marketplace API
    # GET https://mcp-marketplace.io/api/licenses/verify?key={key}
    return {"valid": True, "key": key}


def require_license(tool_name: str) -> str | None:
    """Check license and return error JSON string if invalid, None if valid."""
    import json
    result = check_license()
    if not result["valid"]:
        return json.dumps({
            "error": "premium_required",
            "message": f"{tool_name} requires a FinAgent Pro license. Get one at mcp-marketplace.io/server/finagent",
            "tool": tool_name,
        })
    return None
```

**Step 3: Write sec_filings tool tests**

```python
# tests/test_sec_filings_tool.py
import json
import os
import pytest
from finagent.tools.sec_filings import sec_filings


def test_sec_filings_blocked_without_license():
    os.environ.pop("FINAGENT_LICENSE_KEY", None)
    result = sec_filings(ticker="AAPL", filing_type="10-K")
    data = json.loads(result)
    assert data["error"] == "premium_required"


def test_sec_filings_metadata_with_license(monkeypatch):
    monkeypatch.setenv("FINAGENT_LICENSE_KEY", "test-key")
    result = sec_filings(ticker="AAPL", filing_type="10-K")
    data = json.loads(result)
    assert "available_sections" in data or "error" in data
```

**Step 4: Implement tool**

```python
# src/finagent/tools/sec_filings.py
"""sec_filings MCP tool — paid tier."""

import json
import os

from finagent.license import require_license
from finagent.services.sec_edgar import SECEdgarService

_service = SECEdgarService(
    contact_email=os.environ.get("FINAGENT_CONTACT_EMAIL", "finagent@example.com"),
)


def sec_filings(
    ticker: str,
    filing_type: str,
    section: str | None = None,
    date_range: str | None = None,
) -> str:
    """Read SEC filings for any public company: 10-K, 10-Q, 8-K, proxy statements.
    Extract specific sections like Item 7 (MD&A), risk factors, or business overview.

    PREMIUM FEATURE — requires FinAgent Pro license.

    Args:
        ticker: Stock symbol (e.g., NVDA, AAPL)
        filing_type: Filing type. One of: 10-K, 10-Q, 8-K, DEF-14A
        section: Specific section to extract (e.g., item_7, risk_factors, business_overview).
                 Omit to see available sections.
        date_range: Year or range (e.g., '2024', '2023-2024'). Default: most recent.
    """
    gate = require_license("sec_filings")
    if gate:
        return gate

    result = _service.get_filing_section(
        ticker=ticker,
        filing_type=filing_type,
        section=section,
        date_range=date_range,
    )
    return json.dumps(result, default=str)
```

**Step 5: Add to server.py**

Add import and registration:

```python
from finagent.tools.sec_filings import sec_filings as _sec_filings

@mcp.tool()
def sec_filings(
    ticker: str,
    filing_type: str,
    section: str | None = None,
    date_range: str | None = None,
) -> str:
    """Read SEC filings for any public company: 10-K, 10-Q, 8-K, proxy statements.
    Extract specific sections like Item 7 (MD&A), risk factors, or business overview.

    PREMIUM FEATURE — requires FinAgent Pro license.

    Args:
        ticker: Stock symbol (e.g., NVDA, AAPL)
        filing_type: Filing type. One of: 10-K, 10-Q, 8-K, DEF-14A
        section: Specific section to extract (e.g., item_7, risk_factors, business_overview).
                 Omit to see available sections.
        date_range: Year or range (e.g., '2024', '2023-2024'). Default: most recent.
    """
    return _sec_filings(ticker=ticker, filing_type=filing_type, section=section, date_range=date_range)
```

**Step 6: Run all tests**

```bash
pytest tests/test_license.py tests/test_sec_filings_tool.py -v --timeout=60
```

Expected: All PASS

**Step 7: Commit**

```bash
git add src/finagent/license.py src/finagent/tools/sec_filings.py src/finagent/server.py tests/test_license.py tests/test_sec_filings_tool.py
git commit -m "feat: add sec_filings MCP tool with license gating"
```

---

## Phase 9: Stock Screener

### Task 9: Build screener service and tool

**Files:**
- Create: `src/finagent/services/screener.py`
- Create: `src/finagent/tools/stock_screener.py`
- Modify: `src/finagent/server.py`
- Create: `tests/test_screener.py`
- Create: `tests/test_stock_screener_tool.py`

**Step 1: Write failing tests**

```python
# tests/test_screener.py
import pytest
from finagent.services.screener import ScreenerService


@pytest.fixture
def service(tmp_path):
    return ScreenerService(cache_dir=tmp_path)


def test_screen_by_sector(service):
    results = service.screen(filters={"sector": "Technology"}, limit=5)
    assert isinstance(results, list)
    assert len(results) <= 5


def test_screen_by_market_cap(service):
    results = service.screen(
        filters={"market_cap_min": 100_000_000_000},  # $100B+
        limit=5,
    )
    assert isinstance(results, list)
    for stock in results:
        assert stock.get("market_cap", 0) >= 100_000_000_000


def test_screen_empty_result(service):
    results = service.screen(
        filters={"market_cap_min": 999_999_999_999_999},  # Absurdly high
        limit=5,
    )
    assert isinstance(results, list)
    assert len(results) == 0
```

**Step 2: Run to verify fail**

```bash
pytest tests/test_screener.py -v
```

**Step 3: Implement screener service**

```python
# src/finagent/services/screener.py
"""Stock screener service — filters stocks by financial criteria using yfinance."""

from pathlib import Path

import yfinance as yf

from finagent.cache import FileCache

# A curated list of liquid, well-known tickers to screen across.
# yfinance doesn't have a "screen all stocks" API, so we maintain a universe.
SCREEN_UNIVERSE = [
    # Mega caps
    "AAPL", "MSFT", "GOOGL", "AMZN", "NVDA", "META", "TSLA", "BRK-B",
    "JPM", "V", "JNJ", "UNH", "WMT", "MA", "PG", "HD", "XOM", "CVX",
    "LLY", "ABBV", "MRK", "PFE", "COST", "KO", "PEP", "AVGO", "TMO",
    "ORCL", "CRM", "ADBE", "AMD", "NFLX", "INTC", "QCOM", "TXN",
    "CSCO", "IBM", "NOW", "INTU", "AMAT", "MU", "LRCX", "KLAC",
    # Financials
    "BAC", "WFC", "GS", "MS", "C", "AXP", "BLK", "SCHW",
    # Healthcare
    "ABT", "BMY", "GILD", "AMGN", "MDT", "ISRG", "DHR",
    # Energy
    "COP", "SLB", "EOG", "MPC", "PSX",
    # Consumer
    "NKE", "SBUX", "MCD", "DIS", "CMCSA", "T", "VZ",
    # Industrial
    "CAT", "DE", "HON", "UPS", "BA", "GE", "RTX", "LMT",
]


class ScreenerService:
    def __init__(self, cache_dir: Path | str | None = None):
        self.cache = FileCache(cache_dir=cache_dir)

    def _get_stock_info(self, ticker: str) -> dict | None:
        cache_key = f"screener_info_{ticker}"
        cached = self.cache.get(cache_key)
        if cached:
            return cached

        try:
            t = yf.Ticker(ticker)
            info = t.info
            if not info or not info.get("marketCap"):
                return None

            data = {
                "ticker": ticker,
                "name": info.get("longName") or info.get("shortName", ""),
                "sector": info.get("sector", ""),
                "industry": info.get("industry", ""),
                "market_cap": info.get("marketCap"),
                "pe_ratio": info.get("trailingPE"),
                "forward_pe": info.get("forwardPE"),
                "price": info.get("regularMarketPrice") or info.get("currentPrice"),
                "dividend_yield": info.get("dividendYield"),
                "revenue_growth": info.get("revenueGrowth"),
                "profit_margin": info.get("profitMargins"),
                "return_on_equity": info.get("returnOnEquity"),
                "beta": info.get("beta"),
                "52_week_high": info.get("fiftyTwoWeekHigh"),
                "52_week_low": info.get("fiftyTwoWeekLow"),
            }
            self.cache.set(cache_key, data, ttl_seconds=3600)
            return data
        except Exception:
            return None

    def screen(
        self,
        filters: dict,
        sort_by: str | None = None,
        limit: int = 20,
    ) -> list[dict]:
        """Screen stocks from the universe by criteria."""
        results = []

        for ticker in SCREEN_UNIVERSE:
            info = self._get_stock_info(ticker)
            if info is None:
                continue

            if not self._matches_filters(info, filters):
                continue

            results.append(info)

        # Sort
        if sort_by and results:
            results.sort(
                key=lambda x: x.get(sort_by) or 0,
                reverse=True,
            )

        return results[:limit]

    def _matches_filters(self, info: dict, filters: dict) -> bool:
        for key, value in filters.items():
            if key == "sector" and info.get("sector", "").lower() != value.lower():
                return False
            if key == "market_cap_min" and (info.get("market_cap") or 0) < value:
                return False
            if key == "market_cap_max" and (info.get("market_cap") or float("inf")) > value:
                return False
            if key == "pe_ratio_min" and (info.get("pe_ratio") is None or info["pe_ratio"] < value):
                return False
            if key == "pe_ratio_max" and (info.get("pe_ratio") is not None and info["pe_ratio"] > value):
                return False
            if key == "revenue_growth_min" and (info.get("revenue_growth") is None or info["revenue_growth"] < value):
                return False
            if key == "dividend_yield_min" and (info.get("dividend_yield") is None or info["dividend_yield"] < value):
                return False
        return True
```

**Step 4: Write tool tests**

```python
# tests/test_stock_screener_tool.py
import json
import os
import pytest
from finagent.tools.stock_screener import stock_screener


def test_screener_blocked_without_license():
    os.environ.pop("FINAGENT_LICENSE_KEY", None)
    result = stock_screener(filters={"sector": "Technology"})
    data = json.loads(result)
    assert data["error"] == "premium_required"


def test_screener_works_with_license(monkeypatch):
    monkeypatch.setenv("FINAGENT_LICENSE_KEY", "test-key")
    result = stock_screener(filters={"sector": "Technology"}, limit=3)
    data = json.loads(result)
    assert isinstance(data, list)
```

**Step 5: Implement tool**

```python
# src/finagent/tools/stock_screener.py
"""stock_screener MCP tool — paid tier."""

import json

from finagent.license import require_license
from finagent.services.screener import ScreenerService

_service = ScreenerService()


def stock_screener(
    filters: dict,
    sort_by: str | None = None,
    limit: int = 20,
) -> str:
    """Screen stocks by financial criteria across the market.
    Filter by P/E ratio, market cap, revenue growth, dividend yield, sector, and more.

    PREMIUM FEATURE — requires FinAgent Pro license.

    Args:
        filters: Criteria to filter by. Supported keys:
                 sector (str), market_cap_min/max (float, in dollars),
                 pe_ratio_min/max (float), revenue_growth_min (float, as decimal),
                 dividend_yield_min (float, as decimal)
        sort_by: Field to sort results by (e.g., 'market_cap', 'pe_ratio')
        limit: Max results to return. Default: 20
    """
    gate = require_license("stock_screener")
    if gate:
        return gate

    result = _service.screen(filters=filters, sort_by=sort_by, limit=limit)
    return json.dumps(result, default=str)
```

**Step 6: Add to server.py**

```python
from finagent.tools.stock_screener import stock_screener as _stock_screener

@mcp.tool()
def stock_screener(
    filters: dict,
    sort_by: str | None = None,
    limit: int = 20,
) -> str:
    """Screen stocks by financial criteria across the market.
    Filter by P/E ratio, market cap, revenue growth, dividend yield, sector, and more.

    PREMIUM FEATURE — requires FinAgent Pro license.

    Args:
        filters: Criteria to filter by. Supported keys:
                 sector (str), market_cap_min/max (float, in dollars),
                 pe_ratio_min/max (float), revenue_growth_min (float, as decimal),
                 dividend_yield_min (float, as decimal)
        sort_by: Field to sort results by (e.g., 'market_cap', 'pe_ratio')
        limit: Max results to return. Default: 20
    """
    return _stock_screener(filters=filters, sort_by=sort_by, limit=limit)
```

**Step 7: Run all tests**

```bash
pytest tests/test_screener.py tests/test_stock_screener_tool.py -v --timeout=120
```

Expected: PASS (screener tests may be slow — fetches info for many tickers)

**Step 8: Commit**

```bash
git add src/finagent/services/screener.py src/finagent/tools/stock_screener.py src/finagent/server.py tests/test_screener.py tests/test_stock_screener_tool.py
git commit -m "feat: add stock_screener MCP tool with license gating"
```

---

## Phase 10: HTTP Transport

### Task 10: Add Streamable HTTP transport entry point

**Files:**
- Create: `src/finagent/transport.py`
- Modify: `src/finagent/server.py`

**Step 1: Implement transport module**

```python
# src/finagent/transport.py
"""Transport entry points for FinAgent MCP server."""

from finagent.server import mcp


def run_stdio():
    """Run FinAgent with stdio transport (local process)."""
    mcp.run(transport="stdio")


def run_http(host: str = "0.0.0.0", port: int = 8080):
    """Run FinAgent with Streamable HTTP transport (remote server)."""
    mcp.run(transport="streamable-http", host=host, port=port)


if __name__ == "__main__":
    import sys
    if "--http" in sys.argv:
        port = 8080
        for i, arg in enumerate(sys.argv):
            if arg == "--port" and i + 1 < len(sys.argv):
                port = int(sys.argv[i + 1])
        run_http(port=port)
    else:
        run_stdio()
```

**Step 2: Update server.py main() to support both**

Update the `main()` function in `src/finagent/server.py`:

```python
def main():
    """Entry point — supports stdio (default) and --http flag."""
    import sys
    if "--http" in sys.argv:
        port = 8080
        for i, arg in enumerate(sys.argv):
            if arg == "--port" and i + 1 < len(sys.argv):
                port = int(sys.argv[i + 1])
        mcp.run(transport="streamable-http", host="0.0.0.0", port=port)
    else:
        mcp.run(transport="stdio")
```

**Step 3: Add HTTP entry point to pyproject.toml**

Add to `[project.scripts]`:

```toml
[project.scripts]
finagent = "finagent.server:main"
```

**Step 4: Verify stdio still works**

```bash
python -c "from finagent.server import mcp; print('Tools:', [t.name for t in mcp._tool_manager._tools.values()])"
```

Expected: `Tools: ['financial_data', 'market_news', 'sec_filings', 'stock_screener']`

**Step 5: Commit**

```bash
git add src/finagent/transport.py src/finagent/server.py pyproject.toml
git commit -m "feat: add HTTP transport support (stdio + streamable-http)"
```

---

## Phase 11: Full Integration Test

### Task 11: End-to-end test and final server.py assembly

**Files:**
- Create: `tests/test_server.py`
- Verify: `src/finagent/server.py` (final assembled version)

**Step 1: Write server integration test**

```python
# tests/test_server.py
"""Integration test — verify server has all tools registered."""

from finagent.server import mcp


def test_server_has_all_tools():
    tool_names = [t.name for t in mcp._tool_manager._tools.values()]
    assert "financial_data" in tool_names
    assert "market_news" in tool_names
    assert "sec_filings" in tool_names
    assert "stock_screener" in tool_names


def test_server_name():
    assert mcp.name == "finagent"
```

**Step 2: Verify final server.py is correctly assembled**

The final `src/finagent/server.py` should contain all 4 tool registrations. Read the file and verify all imports and `@mcp.tool()` decorators are present.

**Step 3: Run full test suite**

```bash
pytest tests/ -v --timeout=120
```

Expected: All tests PASS

**Step 4: Commit**

```bash
git add tests/test_server.py
git commit -m "test: add server integration test"
```

---

## Phase 12: Package for Marketplace

### Task 12: README, final packaging, GitHub repo

**Files:**
- Create: `README.md`

**Step 1: Write README**

```markdown
# FinAgent

Financial analysis MCP server for any MCP-compatible AI app (Claude, ChatGPT, Cursor, Copilot, and more).

Get live stock data, read SEC filings, search market news, and screen stocks — all from your AI assistant.

## Tools

| Tool | Tier | Description |
|------|------|-------------|
| `financial_data` | Free | Stock quotes, income statements, balance sheets, cash flow, analyst estimates, insider trades, key ratios |
| `market_news` | Free | Financial news, analyst reactions, market sentiment |
| `sec_filings` | Pro | SEC 10-K, 10-Q, 8-K filing reader with section extraction |
| `stock_screener` | Pro | Screen stocks by P/E, market cap, revenue growth, sector, and more |

## Quick Start

### Install

```bash
pip install finagent
```

### Add to your MCP config

```json
{
  "mcpServers": {
    "finagent": {
      "command": "finagent",
      "env": {
        "FINAGENT_LICENSE_KEY": "your-key-here"
      }
    }
  }
}
```

The license key is optional — free tools work without it.

### Run as HTTP server

```bash
finagent --http --port 8080
```

## Examples

Ask your AI assistant:

- "What's NVIDIA's current stock price and P/E ratio?"
- "Show me Apple's last 4 quarters of revenue"
- "Who's been buying or selling TSLA stock lately?"
- "What are analysts saying about AMZN?"
- "Pull the risk factors from Meta's latest 10-K" (Pro)
- "Find me tech stocks with P/E under 20 and revenue growth over 10%" (Pro)

## Get FinAgent Pro

Unlock SEC filings and stock screening at [mcp-marketplace.io/server/finagent](https://mcp-marketplace.io/server/finagent).

## Data Sources

- **Market data:** Yahoo Finance (free, real-time)
- **SEC filings:** SEC EDGAR (free, official government data)
- **News:** Yahoo Finance news feed (free)
```

**Step 2: Run final test suite**

```bash
pytest tests/ -v --timeout=120
```

**Step 3: Create GitHub repo and push**

```bash
cd ~/Desktop/AI\ Projects/MCPs/finagent
gh repo create finagent --public --source=. --push --description "Financial analysis MCP server — stock data, SEC filings, market news, and screening"
```

**Step 4: Final commit**

```bash
git add README.md
git commit -m "docs: add README for marketplace listing"
git push
```

---

## Summary

| Phase | Tasks | What it delivers |
|-------|-------|-----------------|
| 1 | Task 1 | Project scaffold, deps, empty server |
| 2 | Task 2 | File-based JSON cache |
| 3 | Task 3 | Market data service (yfinance) |
| 4 | Task 4 | `financial_data` MCP tool (free) |
| 5 | Task 5 | News service |
| 6 | Task 6 | `market_news` MCP tool (free) |
| 7 | Task 7 | SEC EDGAR service |
| 8 | Task 8 | `sec_filings` MCP tool (paid) + license gating |
| 9 | Task 9 | `stock_screener` MCP tool (paid) |
| 10 | Task 10 | HTTP transport support |
| 11 | Task 11 | Integration tests |
| 12 | Task 12 | README + GitHub repo |

**Total: 12 tasks, TDD throughout, frequent commits.**
