File size: 5,021 Bytes
b8542e4
178a66c
b8542e4
178a66c
 
b8542e4
 
 
 
 
178a66c
b8542e4
 
 
 
178a66c
 
 
 
 
 
 
 
 
 
 
 
b8542e4
 
 
 
178a66c
 
b8542e4
 
 
 
 
178a66c
 
 
 
b8542e4
178a66c
 
 
 
 
 
 
 
 
 
 
 
 
b8542e4
 
 
178a66c
 
 
 
b8542e4
178a66c
b8542e4
 
 
 
 
 
 
 
178a66c
b8542e4
 
 
 
 
 
 
178a66c
 
b8542e4
 
 
 
 
178a66c
 
b8542e4
178a66c
 
 
 
 
 
 
b8542e4
 
 
 
 
178a66c
 
b8542e4
178a66c
 
 
 
 
 
b8542e4
 
 
 
 
 
178a66c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b8542e4
 
 
178a66c
 
 
 
 
 
 
b8542e4
 
 
178a66c
b8542e4
178a66c
 
b8542e4
 
 
 
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
"""
MCP server exposing DSE equity values from the Uwekezaji backend.

Remote customers connect over HTTP (Streamable HTTP) to the deployed URL.
Local developers can still run `python -m App.mcp.server` over stdio.
"""
from __future__ import annotations

import asyncio
import json
import os
from contextlib import asynccontextmanager
from typing import Any, Optional

from fastmcp import FastMCP
from starlette.applications import Starlette
from starlette.responses import JSONResponse
from starlette.routing import Route

from App.mcp.api_client import UwekezajiApiClient, get_api_base_url
from App.mcp.auth import McpApiKeyMiddleware, get_mcp_api_key
from App.mcp.equities_service import (
    data_source_label,
    get_equity,
    get_equity_price_history,
    list_equities,
)
from db import close_db, init_db

_db_lock = asyncio.Lock()
_db_ready = False
_api_client: Optional[UwekezajiApiClient] = None
_EMBEDDED = os.getenv("UWEKEZAJI_MCP_EMBEDDED", "").strip().lower() in {"1", "true", "yes"}


async def _ensure_db() -> None:
    global _db_ready
    async with _db_lock:
        if _db_ready:
            return
        if _EMBEDDED:
            # Mounted inside main FastAPI; Tortoise is already initialized.
            _db_ready = True
            return
        await init_db()
        _db_ready = True


async def _get_api_client() -> Optional[UwekezajiApiClient]:
    global _api_client
    api_base = get_api_base_url()
    if not api_base:
        return None
    if _api_client is None:
        _api_client = UwekezajiApiClient(api_base)
    return _api_client


@asynccontextmanager
async def _data_session():
    client = await _get_api_client()
    if client is None:
        await _ensure_db()
    try:
        yield client
    finally:
        pass


mcp = FastMCP(
    name="Uwekezaji Equities",
    instructions=(
        "Provides latest market values for Dar es Salaam Stock Exchange listed "
        "equities and ETFs. Government and corporate bonds are excluded."
    ),
)


@mcp.tool
async def list_equity_values() -> dict[str, Any]:
    """List all DSE equities and ETFs with latest prices and market values."""
    async with _data_session() as client:
        return await list_equities(client)


@mcp.tool
async def get_equity_value(symbol: str) -> dict[str, Any]:
    """Get the latest market value snapshot for one equity symbol (e.g. CRDB)."""
    async with _data_session() as client:
        payload = await get_equity(symbol, client)
        if payload is None:
            return {
                "found": False,
                "symbol": symbol.upper(),
                "source": data_source_label(),
                "message": "Equity not found or excluded as a bond.",
            }
        return {"found": True, "source": data_source_label(), "equity": payload}


@mcp.tool
async def get_equity_price_history(symbol: str, days: int = 90) -> dict[str, Any]:
    """Get historical OHLCV price rows for an equity symbol over the last N days."""
    async with _data_session() as client:
        payload = await get_equity_price_history(symbol, days=days, client=client)
        if payload is None:
            return {
                "found": False,
                "symbol": symbol.upper(),
                "source": data_source_label(),
                "message": "Equity not found or excluded as a bond.",
            }
        return {"found": True, **payload}


@mcp.resource("equities://latest")
async def equities_latest_resource() -> str:
    """JSON snapshot of all latest equity values (bonds excluded)."""
    async with _data_session() as client:
        return json.dumps(await list_equities(client), indent=2)


async def _health(_request):
    return JSONResponse(
        {
            "status": "ok",
            "service": "uwekezaji-equities-mcp",
            "transport": "streamable-http",
            "auth_required": bool(get_mcp_api_key()),
            "data_source": data_source_label(),
        }
    )


def create_mcp_http_app():
    """ASGI app for remote MCP clients. Mount at /mcp on the main FastAPI app."""
    mcp_app = mcp.http_app(path="/", transport="streamable-http")
    routes = [Route("/health", _health), Route("/healthz", _health)]
    app = Starlette(routes=routes)
    app.mount("/", mcp_app)

    api_key = get_mcp_api_key()
    if api_key:
        app.add_middleware(McpApiKeyMiddleware, api_key=api_key)
    return app


def main() -> None:
    transport = os.getenv("UWEKEZAJI_MCP_TRANSPORT", "stdio").strip().lower()
    if transport in {"http", "streamable-http", "streamable_http"}:
        host = os.getenv("UWEKEZAJI_MCP_HOST", "0.0.0.0")
        port = int(os.getenv("UWEKEZAJI_MCP_PORT", "8787"))
        mcp.run(transport="streamable-http", host=host, port=port)
        return

    try:
        mcp.run()
    finally:
        if _db_ready and not _EMBEDDED:
            asyncio.run(close_db())
        if _api_client is not None:
            asyncio.run(_api_client.close())


if __name__ == "__main__":
    main()