Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion financedatabase/mcp_server/mcp_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,10 @@
DatabaseProvider,
)
from financedatabase.mcp_server.registry_model import AssetToolRegistry
from financedatabase.mcp_server.site_model import register_site_routes
from financedatabase.mcp_server.site_model import (
SetupPageMiddleware,
register_site_routes,
)
from financedatabase.mcp_server.tools_model import UtilityToolRegistry
from financedatabase.utilities.logger_model import get_logger

Expand Down Expand Up @@ -189,6 +192,12 @@ def main() -> None:
if transport == "streamable-http"
else mcp.sse_app()
)
if transport == "streamable-http":
starlette_app.add_middleware(
SetupPageMiddleware,
website=load_configuration()["server"]["website_url"],
path=mcp.settings.streamable_http_path,
)
starlette_app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
Expand Down
99 changes: 92 additions & 7 deletions financedatabase/mcp_server/site_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

__docformat__ = "google"

from html import escape
from pathlib import Path
from typing import Any

Expand All @@ -20,22 +21,108 @@
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Finance Database MCP</title>
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png">
<link rel="icon" type="image/png" sizes="512x512" href="/icon.png">
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
<style>
body {{ margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center;
background: linear-gradient(135deg, #0f172a 0%, #020617 100%); color: #f8fafc;
font-family: Inter, system-ui, -apple-system, "Segoe UI", sans-serif; }}
main {{ width: 100%; max-width: 640px; padding: 48px 24px; box-sizing: border-box; }}
img {{ width: 88px; height: 88px; border-radius: 22px; }}
h1 {{ margin: 20px 0 8px; font-size: 40px; letter-spacing: -0.02em; }}
h1 span, a {{ color: #38bdf8; }}
p {{ color: #cbd5e1; line-height: 1.6; }}
ol {{ padding-left: 22px; color: #cbd5e1; line-height: 1.7; }}
code {{ font-family: "JetBrains Mono", ui-monospace, monospace; font-size: 15px; color: #f8fafc;
background: rgba(30, 41, 59, 0.8); border: 1px solid rgba(255, 255, 255, 0.1); border-radius: 6px;
padding: 2px 6px; overflow-wrap: anywhere; }}
.button {{ display: inline-block; margin-top: 16px; padding: 12px 22px; border-radius: 999px; color: #020617;
background: #38bdf8; font-weight: 700; text-decoration: none; }}
</style>
</head>
<body>
<p>This is the Finance Database MCP server; MCP clients connect at <code>/mcp</code>.
See <a href="{website}">{website}</a> for how to add it.</p>
<main>
<img src="/icon.png" alt="">
<h1>Finance Database <span>MCP</span></h1>
<p>This is an MCP server: it lets AI assistants such as Claude, Copilot and Cursor search and filter
categorised equities, ETFs, indices, currencies and cryptocurrencies. It is meant for MCP clients, not browsers.
To set it up:</p>
<ol>
<li>Add it as a remote (HTTP) MCP server with the URL <code>{url}</code>. In Claude: Customize →
Connectors → Add custom connector.</li>
<li>No sign-in or API key is needed.</li>
<li>Ask, for example: "Which Japanese semiconductor companies are listed?"</li>
</ol>
<p>Or run it locally: <code>uvx --from "financedatabase[mcp]" financedatabase-mcp-setup</code></p>
<a class="button" href="{website}">Setup guide and documentation</a>
</main>
</body>
</html>
"""


def render_setup_page(request: Request, website: str) -> HTMLResponse:
"""
The setup page for people who open the server in a browser.

Args:
request (Request): The request, which gives the server's own URL.
website (str): The documentation page.

Returns:
HTMLResponse: The page.
"""
return HTMLResponse(
PAGE.format(url=escape(f"{request.base_url}mcp"), website=escape(website))
)


def check_browser_request(scope: dict) -> bool:
"""
Whether a request is a browser opening the page: a GET asking for HTML. MCP clients POST
JSON or GET an event stream, so they never match.

Args:
scope (dict): The ASGI scope.

Returns:
bool: True for a browser.
"""
accept = dict(scope.get("headers", [])).get(b"accept", b"").decode().lower()
return (
scope.get("method") == "GET"
and "text/html" in accept
and "text/event-stream" not in accept
)


class SetupPageMiddleware:
"""
Serve the setup page to browsers at the MCP endpoint; everything else passes through.
"""

def __init__(self, app: Any, website: str, path: str = "/mcp") -> None:
self.app, self.website, self.path = app, website, path.rstrip("/")

async def __call__(self, scope: dict, receive: Any, send: Any) -> None:
if (
scope["type"] == "http"
and scope["path"].rstrip("/") == self.path
and check_browser_request(scope)
):
await render_setup_page(Request(scope, receive), self.website)(
scope, receive, send
)
return
await self.app(scope, receive, send)


def register_site_routes(mcp: Any, website: str) -> None:
"""
Serve the icons and a short page at the root, so browsers and favicon lookups find the
Serve the icons and the setup page at the root, so browsers and favicon lookups find the
Finance Database icon.

Args:
Expand All @@ -60,8 +147,6 @@ async def show_icon(
headers={"Cache-Control": "public, max-age=86400"},
)

page = PAGE.format(website=website)

@mcp.custom_route("/", methods=["GET"])
async def show_page(request: Request) -> HTMLResponse: # noqa: ARG001
return HTMLResponse(page)
async def show_page(request: Request) -> HTMLResponse:
return render_setup_page(request, website)
51 changes: 50 additions & 1 deletion tests/mcp_server/test_site_model.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
"""Site Model Tests"""

from mcp.server.fastmcp import FastMCP
from starlette.requests import Request
from starlette.testclient import TestClient

from financedatabase.mcp_server.site_model import register_site_routes
from financedatabase.mcp_server.site_model import (
SetupPageMiddleware,
register_site_routes,
render_setup_page,
)


def test_icons_and_root_page_are_served() -> None:
Expand All @@ -25,3 +30,47 @@ def test_icons_and_root_page_are_served() -> None:
assert "<title>Finance Database MCP</title>" in page.text
assert 'href="/favicon-32.png"' in page.text
assert "https://example.com/mcp" in page.text
assert "<code>http://testserver/mcp</code>" in page.text


def test_browsers_get_the_setup_page_and_mcp_clients_do_not() -> None:
"""Test that a browser opening the MCP endpoint gets the setup page and MCP clients pass through."""
mcp = FastMCP(name="Finance Database")
app = mcp.streamable_http_app()
app.add_middleware(SetupPageMiddleware, website="https://example.com/mcp")

with TestClient(app, base_url="http://localhost:8000") as client:
browser = client.get(
"/mcp", headers={"Accept": "text/html,application/xhtml+xml,*/*;q=0.8"}
)
assert browser.status_code == 200
assert browser.headers["content-type"].startswith("text/html")
assert "Setup guide and documentation" in browser.text

stream = client.get("/mcp", headers={"Accept": "text/event-stream, text/html"})
assert "Setup guide" not in stream.text

initialize = client.post(
"/mcp",
headers={"Accept": "application/json, text/event-stream"},
json={
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-06-18",
"capabilities": {},
"clientInfo": {"name": "test", "version": "1"},
},
},
)
assert initialize.status_code == 200
assert "Finance Database" in initialize.text


def test_setup_page_escapes_what_it_shows() -> None:
"""Test that interpolated values cannot inject HTML into the setup page."""
scope = {"type": "http", "method": "GET", "scheme": "https", "path": "/mcp"}
scope |= {"query_string": b"", "server": ("localhost", 443), "headers": []}
page = render_setup_page(Request(scope), 'https://example.com/"><script>').body
assert b"<script>" not in page and b"&quot;&gt;&lt;script&gt;" in page
Loading