[BugFix] MCP - expose correct header for session id (#7160)

* [BugFix] MCP - expose correct header for session id

* Pin FastMCP to <2.9

The update casued problems with converting tools from OpenAPI specs

* Update default settings

* Add exclusion for all POST requests in route filtering

* fix compress input and output schemas with newer fastmcp versions

* Add unit tests for MCP server, utilities and settings

* add missing init file

* skip tests for python 3.9

* Add setup instructions for VS Code

* update fastmcp version and lock file

---------

Co-authored-by: Danglewood <85772166+deeleeramone@users.noreply.github.com>
Co-authored-by: DidierRLopes <didierrlopes@gmail.com>
This commit is contained in:
Magnus
2025-07-13 00:26:15 +02:00
committed by GitHub
parent 4973838496
commit 7b520e5f8a
13 changed files with 690 additions and 55 deletions

View File

@@ -2,24 +2,28 @@
Model Context Protocol (MCP) server extension for OpenBB Platform. This extension enables LLM agents to interact with OpenBB Platform's REST API endpoints through the MCP protocol.
The server provides management tools that allow agents to explore different options and dynamically adjust their active toolset. This prevents agents from being overwhelmed with too many tools while allowing them to discover and activate only the tools they need for specific tasks.
In addition, the server provides discovery tools that allow agents to explore different options and dynamically adjust their active toolset. This prevents agents from being overwhelmed with too many tools while allowing them to discover and activate only the tools they need for specific tasks.
Using these dynamic tool discovery, has one major drawback, it makes the server a single-user server. The tool updates are global, so if one user updates a tool, it will be updated for all users. If you plan to server multiple users, you should disable tool discovery, and instead use the `allowed_tool_categories` and `default_tool_categories` settings to control the tools that are available to the users.
## Installation
## Installation & Usage
```bash
pip install openbb-mcp-server
```
## Usage
Start the OpenBB MCP server with default settings:
```bash
openbb-mcp
```
Or use the `uvx` command:
```bash
uvx --from openbb-mcp-server --with openbb openbb-mcp
```
### Command Line Options
- `--host`: Host to bind to (default: 127.0.0.1)
@@ -45,10 +49,119 @@ openbb-mcp --allowed-categories equity,crypto,news
openbb-mcp --no-tool-discovery
```
#### Claude Desktop:
To connect the OpenBB MCP server with Claude Desktop, you need to configure it as a custom tool server. Here are the steps:
1. Locate the settings or configuration file for Claude Desktop where you can define custom MCP servers.
2. Add the following entry to your `mcpServers` configuration. This will configure Claude Desktop to launch the OpenBB MCP server automatically using `stdio` for communication.
```json
{
"mcpServers": {
"openbb-mcp": {
"command": "uvx",
"args": [
"--from",
"openbb-mcp-server",
"--with",
"openbb",
"openbb-mcp",
"--transport",
"stdio"
]
}
}
}
```
3. Ensure that `uvx`, is installed and available in your system's PATH. If not, follow the installation instructions.
4. Restart Claude Desktop to apply the changes. You should now see "openbb-mcp" as an available tool source.
#### Cursor:
To use OpenBB tools within Cursor, you first need to run the MCP server and then tell Cursor how to connect to it.
**Step 1: Run the OpenBB MCP Server**
Open your terminal and start the server. You can use the default settings or customize it.
For a default setup, run:
```bash
openbb-mcp
```
The server will start on `http://127.0.0.1:8001`.
**Step 2: Configure Cursor**
Add the following configuration to the `mcpServers` object in your `mcp.json` file. If the `mcpServers` object doesn't exist, you can add it.
```json
{
"mcpServers": {
"openbb-mcp": {
"url": "http://localhost:8001/mcp/"
}
}
}
```
#### VS Code
**Step 1: Enable MCP in VS Code Settings**
Enter `shift + command + p` and open "Preferences: Open User Settings"
Search for "mcp", and the item should show up under "Chat". Check the box to enable MCP server integrations.
<img width="1278" height="411" alt="vs-code-mcp-enable" src="https://github.com/user-attachments/assets/5ace29de-e59c-45c3-b751-c6d92614e0ee" />
**Step 2: Run the OpenBB MCP Server**
Open your terminal and start the server. You can use the default settings or customize it.
For a default setup, run:
```bash
openbb-mcp
```
The server will start on `http://127.0.0.1:8001`.
**Step 3: Add Server as HTTP**
Enter `shift + command + p` and select "MCP: Add Server".
<img width="595" height="412" alt="vs-code-mcp-commands" src="https://github.com/user-attachments/assets/9b13a5b6-ec20-43e2-9aae-7982e9fdcae6" />
Press enter and then select HTTP.
<img width="594" height="174" alt="vs-code-mcp-add-http" src="https://github.com/user-attachments/assets/d2a06e4b-404a-4317-ad2c-241c1ac5e04b" />
Copy the URL from the console of the running server, and enter it
```sh
INFO Starting MCP server 'OpenBB MCP' with transport 'streamable-http' on http://127.0.0.1:8001/mcp
```
Give it a name, and add it either as global or to a workspace. The end result will create a `mcp.json` VS Code configuration file for the chosen domain.
<img width="402" height="195" alt="vs-code-mcp-json" src="https://github.com/user-attachments/assets/fdea335b-0523-4103-be3e-b5d9675c25b3" />
The tools can now be added as context to the chat.
<img width="601" height="442" alt="vs-code-mcp-tools" src="https://github.com/user-attachments/assets/06c39248-aedd-4f53-9560-6dfbae1efaf8" />
**Note**: When adding to the Cline extension, set `--transport sse` when starting the server.
## Configuration
The server can be configured through multiple methods:
> **Note:** For some data providers you need to set your API key in the `~/.openbb_platform/user_settings.json` file.
### 1. Configuration File (Recommended)
The server automatically creates and uses `~/.openbb_platform/mcp_settings.json`:
@@ -60,7 +173,7 @@ The server automatically creates and uses `~/.openbb_platform/mcp_settings.json`
"default_tool_categories": ["all"],
"allowed_tool_categories": null,
"enable_tool_discovery": true,
"describe_responses": true
"describe_responses": false
}
```
@@ -88,7 +201,7 @@ Command line arguments override both configuration file and environment variable
| default_tool_categories | list[string] | ["all"] | Categories enabled at startup. Use "all" to enable all categories, or specify individual categories |
| allowed_tool_categories | list[string] | null | If set, restricts available categories to this list |
| enable_tool_discovery | boolean | true | Enable discovery and management tools |
| describe_responses | boolean | true | Include response information in tool descriptions |
| describe_responses | boolean | false | Include response information in tool descriptions |
## Tool Categories
@@ -110,7 +223,7 @@ Each category contains subcategories that group related functionality (e.g., `eq
## Tool Discovery
When `enable_tool_discovery` is enabled (default), the server provides management tools that allow agents to:
When `enable_tool_discovery` is enabled (default), the server provides discovery tools that allow agents to:
- Discover available tool categories and subcategories
- See tool counts and descriptions before activating
@@ -119,4 +232,4 @@ When `enable_tool_discovery` is enabled (default), the server provides managemen
To take full advantage of minimal startup tools, you should set the `--default-categories` argument to `admin` this will enable only the discovery tools at startup.
For multi-client deployments or scenarios where you want a fixed toolset, disable tool discovery with `--no-tool-discovery`.
For multi-client deployments or scenarios where you want a fixed toolset, disable tool discovery with `--no-tool-discovery`.

View File

@@ -5,7 +5,6 @@ import re
import sys
from typing import Annotated
import uvicorn
from fastapi import FastAPI
from fastmcp import FastMCP
from fastmcp.server.openapi import (
@@ -14,6 +13,7 @@ from fastmcp.server.openapi import (
OpenAPIResourceTemplate,
OpenAPITool,
)
from fastmcp.utilities.json_schema import compress_schema
from fastmcp.utilities.openapi import HTTPRoute
from openbb_core.api.rest_api import app
from openbb_core.app.service.system_service import SystemService
@@ -59,7 +59,7 @@ def create_mcp_server(settings: MCPSettings, fastapi_app: FastAPI) -> FastMCPOpe
route: HTTPRoute,
component: OpenAPITool | OpenAPIResource | OpenAPIResourceTemplate,
) -> None:
"""Attach category/tool tags & disable tools that are not admin."""
"""Attach category/tool tags & disable tools based on settings."""
if not isinstance(component, OpenAPITool):
return
@@ -90,11 +90,18 @@ def create_mcp_server(settings: MCPSettings, fastapi_app: FastAPI) -> FastMCPOpe
)
component.tags.add(category)
# Compress schemas
component.parameters = compress_schema(component.parameters)
if hasattr(component, "output_schema") and component.output_schema:
component.output_schema = compress_schema(component.output_schema)
# Remove unnecessary details from descriptions
if not settings.describe_responses:
component.description = _extract_brief_description(
component.description or ""
)
# Enable tool if it's in the default tool categories
if "all" in settings.default_tool_categories or any(
tag in settings.default_tool_categories for tag in component.tags
):
@@ -243,18 +250,18 @@ def main():
allow_origins=cors_settings.allow_origins,
allow_methods=cors_settings.allow_methods,
allow_headers=cors_settings.allow_headers,
allow_credentials=True,
expose_headers=["Mcp-Session-Id"],
),
]
# Create ASGI app with cors middleware
http_app = mcp.http_app(
middleware=cors_middleware,
mcp.run(
transport=args.transport,
stateless_http=True,
host=args.host,
port=args.port,
middleware=cors_middleware,
)
uvicorn.run(http_app, host=args.host, port=args.port)
except Exception as e:
logger.error("Failed to start MCP server: %s", e)
sys.exit(1)

View File

@@ -46,4 +46,13 @@ def create_route_maps_from_settings(settings: MCPSettings) -> List[RouteMap]:
)
)
# Always exclude all POST requests (technical and quantitative endpoints)
route_maps.append(
RouteMap(
methods=["POST"],
pattern=r".*",
mcp_type=MCPType.EXCLUDE,
)
)
return route_maps

View File

@@ -49,7 +49,7 @@ the exact same operations available to REST clients.""",
# Response configuration
describe_responses: bool = Field(
default=True,
default=False,
description="Include response types in tool descriptions",
alias="OPENBB_MCP_DESCRIBE_RESPONSES",
)

View File

@@ -514,6 +514,30 @@ ssh = ["bcrypt (>=3.1.5)"]
test = ["certifi (>=2024)", "cryptography-vectors (==45.0.4)", "pretend (>=0.7)", "pytest (>=7.4.0)", "pytest-benchmark (>=4.0)", "pytest-cov (>=2.10.1)", "pytest-xdist (>=3.5.0)"]
test-randomorder = ["pytest-randomly"]
[[package]]
name = "cyclopts"
version = "3.22.2"
description = "Intuitive, easy CLIs based on type hints."
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "cyclopts-3.22.2-py3-none-any.whl", hash = "sha256:6681b0815fa2de2bccc364468fd25b15aa9617cb505c0b16ca62e2b18a57619e"},
{file = "cyclopts-3.22.2.tar.gz", hash = "sha256:d3495231af6ae86479579777d212ddf77b113200f828badeaf401162ed87227d"},
]
[package.dependencies]
attrs = ">=23.1.0"
docstring-parser = {version = ">=0.15", markers = "python_version < \"4.0\""}
rich = ">=13.6.0"
rich-rst = ">=1.3.1,<2.0.0"
typing-extensions = {version = ">=4.8.0", markers = "python_version < \"3.11\""}
[package.extras]
toml = ["tomli (>=2.0.0) ; python_version < \"3.11\""]
trio = ["trio (>=0.10.0)"]
yaml = ["pyyaml (>=6.0.1)"]
[[package]]
name = "distro"
version = "1.9.0"
@@ -547,6 +571,30 @@ idna = ["idna (>=3.7)"]
trio = ["trio (>=0.23)"]
wmi = ["wmi (>=1.5.1)"]
[[package]]
name = "docstring-parser"
version = "0.16"
description = "Parse Python docstrings in reST, Google and Numpydoc format"
optional = false
python-versions = ">=3.6,<4.0"
groups = ["main"]
files = [
{file = "docstring_parser-0.16-py3-none-any.whl", hash = "sha256:bf0a1387354d3691d102edef7ec124f219ef639982d096e26e3b60aeffa90637"},
{file = "docstring_parser-0.16.tar.gz", hash = "sha256:538beabd0af1e2db0146b6bd3caa526c35a34d61af9fd2887f3a8a27a739aa6e"},
]
[[package]]
name = "docutils"
version = "0.21.2"
description = "Docutils -- Python Documentation Utilities"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "docutils-0.21.2-py3-none-any.whl", hash = "sha256:dafca5b9e384f0e419294eb4d2ff9fa826435bf15f15b7bd45723e8ad76811b2"},
{file = "docutils-0.21.2.tar.gz", hash = "sha256:3a6b18732edf182daa3cd12775bbb338cf5691468f91eeeb109deff6ebfa986f"},
]
[[package]]
name = "email-validator"
version = "2.2.0"
@@ -604,26 +652,27 @@ standard = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.5)", "htt
[[package]]
name = "fastmcp"
version = "2.10.1"
description = "The fast, Pythonic way to build MCP servers."
version = "2.10.5"
description = "The fast, Pythonic way to build MCP servers and clients."
optional = false
python-versions = ">=3.10"
groups = ["main"]
files = [
{file = "fastmcp-2.10.1-py3-none-any.whl", hash = "sha256:17d0acea04eeb3464c9eca42b6774fb06b38b72cface9af6a7482b3aa561db13"},
{file = "fastmcp-2.10.1.tar.gz", hash = "sha256:450c72e523926a2203c7eecdb4a8b0507506667bc8736b8b7bb44f6312424649"},
{file = "fastmcp-2.10.5-py3-none-any.whl", hash = "sha256:ab218f6a66b61f6f83c413d37aa18f5c30882c44c8925f39ecd02dd855826540"},
{file = "fastmcp-2.10.5.tar.gz", hash = "sha256:f829e0b11c4d136db1d81e20e8acb19cf5108f64059482d1853f3c940326cf04"},
]
[package.dependencies]
authlib = ">=1.5.2"
cyclopts = ">=3.0.0"
exceptiongroup = ">=1.2.2"
httpx = ">=0.28.1"
mcp = ">=1.10.0"
openapi-pydantic = ">=0.5.1"
pydantic = {version = ">=2.11.7", extras = ["email"]}
pyperclip = ">=1.9.0"
python-dotenv = ">=1.1.0"
rich = ">=13.9.4"
typer = ">=0.15.2"
[package.extras]
websockets = ["websockets (>=15.0.1)"]
@@ -1710,6 +1759,17 @@ dev = ["coverage[toml] (==5.0.4)", "cryptography (>=3.4.0)", "pre-commit", "pyte
docs = ["sphinx", "sphinx-rtd-theme", "zope.interface"]
tests = ["coverage[toml] (==5.0.4)", "pytest (>=6.0.0,<7.0.0)"]
[[package]]
name = "pyperclip"
version = "1.9.0"
description = "A cross-platform clipboard module for Python. (Only handles plain text for now.)"
optional = false
python-versions = "*"
groups = ["main"]
files = [
{file = "pyperclip-1.9.0.tar.gz", hash = "sha256:b7de0142ddc81bfc5c7507eea19da920b92252b548b96186caf94a5e2527d310"},
]
[[package]]
name = "python-dateutil"
version = "2.9.0.post0"
@@ -1823,6 +1883,22 @@ typing-extensions = {version = ">=4.0.0,<5.0", markers = "python_version < \"3.1
[package.extras]
jupyter = ["ipywidgets (>=7.5.1,<9)"]
[[package]]
name = "rich-rst"
version = "1.3.1"
description = "A beautiful reStructuredText renderer for rich"
optional = false
python-versions = ">=3.6"
groups = ["main"]
files = [
{file = "rich_rst-1.3.1-py3-none-any.whl", hash = "sha256:498a74e3896507ab04492d326e794c3ef76e7cda078703aa592d1853d91098c1"},
{file = "rich_rst-1.3.1.tar.gz", hash = "sha256:fad46e3ba42785ea8c1785e2ceaa56e0ffa32dbe5410dec432f37e4107c4f383"},
]
[package.dependencies]
docutils = "*"
rich = ">=12.0.0"
[[package]]
name = "rpds-py"
version = "0.26.0"
@@ -2005,18 +2081,6 @@ files = [
{file = "ruff-0.7.4.tar.gz", hash = "sha256:cd12e35031f5af6b9b93715d8c4f40360070b2041f81273d0527683d5708fce2"},
]
[[package]]
name = "shellingham"
version = "1.5.4"
description = "Tool to Detect Surrounding Shell"
optional = false
python-versions = ">=3.7"
groups = ["main"]
files = [
{file = "shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686"},
{file = "shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de"},
]
[[package]]
name = "six"
version = "1.17.0"
@@ -2080,24 +2144,6 @@ anyio = ">=3.6.2,<5"
[package.extras]
full = ["httpx (>=0.27.0,<0.29.0)", "itsdangerous", "jinja2", "python-multipart (>=0.0.18)", "pyyaml"]
[[package]]
name = "typer"
version = "0.16.0"
description = "Typer, build great CLIs. Easy to code. Based on Python type hints."
optional = false
python-versions = ">=3.7"
groups = ["main"]
files = [
{file = "typer-0.16.0-py3-none-any.whl", hash = "sha256:1f79bed11d4d02d4310e3c1b7ba594183bcedb0ac73b27a9e5f28f6fb5b98855"},
{file = "typer-0.16.0.tar.gz", hash = "sha256:af377ffaee1dbe37ae9440cb4e8f11686ea5ce4e9bae01b84ae7c63b87f1dd3b"},
]
[package.dependencies]
click = ">=8.0.0"
rich = ">=10.11.0"
shellingham = ">=1.3.0"
typing-extensions = ">=3.7.4.3"
[[package]]
name = "typing-extensions"
version = "4.14.0"
@@ -2420,4 +2466,4 @@ type = ["pytest-mypy"]
[metadata]
lock-version = "2.1"
python-versions = ">=3.10,<3.13"
content-hash = "046056a5a3bbaeecc2b143ee4c51f27fd73e9f79e12056e895d0d87a5ac6bc64"
content-hash = "1928c1f045c93b0b3b2b7f36fb8ad9728fb74268b9d96e79e1ec3825b399bb25"

View File

@@ -1,6 +1,6 @@
[tool.poetry]
name = "openbb-mcp-server"
version = "0.0.1"
version = "0.0.4"
description = "OpenBB Platform MCP Server"
authors = ["OpenBB <hello@openbb.co>"]
license = "AGPL-3.0-only"
@@ -16,7 +16,7 @@ openbb-mcp = "openbb_mcp_server.main:main"
[tool.poetry.dependencies]
python = ">=3.10,<3.13"
openbb-core = "^1.4.3"
fastmcp = "^2.8.0"
fastmcp = "^2.10.5"
[build-system]
requires = ["poetry-core>=1.0.0"]

View File

@@ -0,0 +1,9 @@
"""Tests for the MCP server extension."""
import sys
import pytest
# Skip all tests if Python version < 3.10
if sys.version_info < (3, 10):
pytest.skip("MCP server requires Python 3.10+", allow_module_level=True)

View File

@@ -0,0 +1,101 @@
"""Unit tests for config utilities."""
import json
from pathlib import Path
from unittest.mock import mock_open, patch
import pytest
from openbb_mcp_server.utils.config import (
get_mcp_config_path,
load_mcp_settings,
load_mcp_settings_with_overrides,
load_settings_from_env,
parse_args,
save_mcp_settings,
)
from openbb_mcp_server.utils.settings import MCPSettings
def test_get_mcp_config_path():
path = get_mcp_config_path()
assert isinstance(path, Path)
assert path.name == "mcp_settings.json"
@patch("pathlib.Path.exists")
@patch("builtins.open", new_callable=mock_open)
def test_load_mcp_settings_existing(mock_file, mock_exists):
mock_exists.return_value = True
mock_file.return_value.read.return_value = json.dumps(
{"name": "Test", "description": "Test desc"}
)
settings = load_mcp_settings()
assert isinstance(settings, MCPSettings)
assert settings.name == "Test"
assert settings.description == "Test desc"
@patch("pathlib.Path.exists", return_value=False)
@patch("builtins.open", new_callable=mock_open)
@patch("openbb_mcp_server.utils.config.save_mcp_settings")
def test_load_mcp_settings_create_default(mock_save, _mock_file, _mock_exists):
settings = load_mcp_settings()
assert isinstance(settings, MCPSettings)
mock_save.assert_called_once_with(settings)
@patch("builtins.open", new_callable=mock_open)
def test_save_mcp_settings(mock_file):
settings = MCPSettings(name="Test")
save_mcp_settings(settings)
mock_file.assert_called_once()
handle = mock_file()
written = "".join(call.args[0] for call in handle.write.call_args_list)
expected = json.dumps(settings.model_dump(), indent=2)
assert written == expected
@patch("os.environ")
def test_load_settings_from_env(mock_env):
env_dict = {
"OPENBB_MCP_NAME": "Env Test",
"OPENBB_MCP_DEFAULT_TOOL_CATEGORIES": "cat1,cat2",
}
mock_env.__getitem__.side_effect = lambda k: env_dict.get(k)
mock_env.__contains__.side_effect = lambda k: k in env_dict
env_settings = load_settings_from_env()
assert env_settings["name"] == "Env Test"
assert env_settings["default_tool_categories"] == ["cat1", "cat2"]
def test_load_settings_from_env_empty():
with patch("os.environ", {}):
assert load_settings_from_env() == {}
@patch("openbb_mcp_server.utils.config.load_mcp_settings")
@patch("openbb_mcp_server.utils.config.load_settings_from_env")
def test_load_mcp_settings_with_overrides(mock_env, mock_load):
mock_load.return_value = MCPSettings(name="File")
mock_env.return_value = {"name": "Env"}
settings = load_mcp_settings_with_overrides(name="Override")
assert settings.name == "Override"
@patch("sys.argv", ["script.py", "--host", "127.0.0.1", "--port", "9999"])
def test_parse_args():
args = parse_args()
assert args.host == "127.0.0.1"
assert args.port == 9999
assert not args.no_tool_discovery
@patch("sys.argv", ["script.py", "--help"])
def test_parse_args_help(capsys):
with pytest.raises(SystemExit):
parse_args()
captured = capsys.readouterr()
assert "usage:" in captured.out

View File

@@ -0,0 +1,89 @@
"""Unit tests for main module."""
from unittest.mock import ANY, MagicMock, patch
from fastapi import FastAPI
from openbb_mcp_server.main import _extract_brief_description, create_mcp_server, main
from openbb_mcp_server.utils.settings import MCPSettings
def test_extract_brief_description():
full_desc = """Brief description.
**Query Parameters:**
Details..."""
assert _extract_brief_description(full_desc) == "Brief description."
no_desc = ""
assert _extract_brief_description(no_desc) == "No description available"
no_split = "Just brief."
assert _extract_brief_description(no_split) == "Just brief."
responses_split = """Brief.
**Responses:**
Details..."""
assert _extract_brief_description(responses_split) == "Brief."
@patch("openbb_mcp_server.main.FastMCP.from_fastapi")
@patch("openbb_mcp_server.main.ToolRegistry")
def test_create_mcp_server(_mock_registry, mock_from_fastapi):
settings = MCPSettings(enable_tool_discovery=False)
app = FastAPI()
mock_mcp = MagicMock()
mock_from_fastapi.return_value = mock_mcp
mcp = create_mcp_server(settings, app)
assert mcp == mock_mcp
mock_from_fastapi.assert_called_once()
# Test with discovery enabled
settings = MCPSettings(enable_tool_discovery=True)
create_mcp_server(settings, app)
@patch("openbb_mcp_server.main.parse_args")
@patch("openbb_mcp_server.main.load_mcp_settings_with_overrides")
@patch("openbb_mcp_server.main.create_mcp_server")
@patch("openbb_mcp_server.main.SystemService")
@patch("sys.exit")
def test_main(mock_exit, mock_system, mock_create, mock_load, mock_parse):
mock_args = MagicMock()
mock_args.transport = "http"
mock_args.host = "localhost"
mock_args.port = 8000
mock_args.allowed_categories = None
mock_args.default_categories = "all"
mock_args.no_tool_discovery = False
mock_parse.return_value = mock_args
mock_settings = MCPSettings()
mock_load.return_value = mock_settings
mock_mcp = MagicMock()
mock_create.return_value = mock_mcp
mock_system.return_value.system_settings.api_settings.cors.allow_origins = ["*"]
mock_system.return_value.system_settings.api_settings.cors.allow_methods = ["*"]
mock_system.return_value.system_settings.api_settings.cors.allow_headers = ["*"]
main()
mock_mcp.run.assert_called_once_with(
transport="http",
host="localhost",
port=8000,
middleware=ANY,
)
# Test stdio transport
mock_args.transport = "stdio"
main()
mock_mcp.run.assert_called_with(transport="stdio")
# Test exception handling
mock_create.side_effect = Exception("Test error")
main()
mock_exit.assert_called_once_with(1)

View File

@@ -0,0 +1,140 @@
"""Unit tests for ToolRegistry."""
# pylint: disable=redefined-outer-name
from unittest.mock import MagicMock
import pytest
from fastmcp.server.openapi import OpenAPITool
from openbb_mcp_server.registry import ToolRegistry
from openbb_mcp_server.tool_models import ToggleResult
@pytest.fixture
def tool_registry():
return ToolRegistry()
def test_register_tool(tool_registry):
tool = OpenAPITool(
MagicMock(), "/dummy", name="test_tool", description="Test", parameters={}
)
tool_registry.register_tool(
category="test_cat", subcategory="test_sub", tool_name="test_tool", tool=tool
)
assert "test_cat" in tool_registry.get_categories()
assert "test_tool" in tool_registry.get_category_tools("test_cat", "test_sub")
assert tool_registry.get_tool("test_tool") == tool
def test_get_categories(tool_registry):
tool1 = OpenAPITool(
MagicMock(), "/dummy1", name="tool1", description="Tool1", parameters={}
)
tool2 = OpenAPITool(
MagicMock(), "/dummy2", name="tool2", description="Tool2", parameters={}
)
tool_registry.register_tool(
category="cat1", subcategory="sub1", tool_name="tool1", tool=tool1
)
tool_registry.register_tool(
category="cat2", subcategory="sub2", tool_name="tool2", tool=tool2
)
categories = tool_registry.get_categories()
assert set(categories.keys()) == {"cat1", "cat2"}
assert "sub1" in categories["cat1"]
assert "tool1" in categories["cat1"]["sub1"]
def test_get_category_tools(tool_registry):
tool1 = OpenAPITool(
MagicMock(), "/dummy1", name="tool1", description="Tool1", parameters={}
)
tool2 = OpenAPITool(
MagicMock(), "/dummy2", name="tool2", description="Tool2", parameters={}
)
tool3 = OpenAPITool(
MagicMock(), "/dummy3", name="tool3", description="Tool3", parameters={}
)
tool_registry.register_tool(
category="cat1", subcategory="sub1", tool_name="tool1", tool=tool1
)
tool_registry.register_tool(
category="cat1", subcategory="sub1", tool_name="tool2", tool=tool2
)
tool_registry.register_tool(
category="cat1", subcategory="sub2", tool_name="tool3", tool=tool3
)
# Specific subcategory
tools_sub1 = tool_registry.get_category_tools("cat1", "sub1")
assert set(tools_sub1.keys()) == {"tool1", "tool2"}
# All subcategories
all_tools = tool_registry.get_category_tools("cat1")
assert set(all_tools.keys()) == {"tool1", "tool2", "tool3"}
# Non-existent
assert tool_registry.get_category_tools("nonexistent") == {}
def test_get_tool(tool_registry):
tool = OpenAPITool(
MagicMock(), "/dummy", name="test", description="Test", parameters={}
)
tool_registry.register_tool(
category="cat", subcategory="sub", tool_name="test", tool=tool
)
assert tool_registry.get_tool("test") == tool
assert tool_registry.get_tool("nonexistent") is None
def test_toggle_tools(tool_registry):
tool1 = OpenAPITool(
MagicMock(), "/dummy1", name="tool1", description="Tool1", parameters={}
)
tool2 = OpenAPITool(
MagicMock(), "/dummy2", name="tool2", description="Tool2", parameters={}
)
tool_registry.register_tool(
category="cat", subcategory="sub", tool_name="tool1", tool=tool1
)
tool_registry.register_tool(
category="cat", subcategory="sub", tool_name="tool2", tool=tool2
)
# Enable
result = tool_registry.toggle_tools(["tool1", "tool2", "missing"], enable=True)
assert isinstance(result, ToggleResult)
assert result.action == "activated"
assert set(result.successful) == {"tool1", "tool2"}
assert result.failed == ["missing"]
assert tool1.enabled
assert tool2.enabled
# Disable
result = tool_registry.toggle_tools(["tool1"], enable=False)
assert result.action == "deactivated"
assert result.successful == ["tool1"]
assert result.failed == []
assert not tool1.enabled
assert tool2.enabled # Still enabled
def test_clear(tool_registry):
tool = OpenAPITool(
MagicMock(), "/dummy", name="test", description="Test", parameters={}
)
tool_registry.register_tool(
category="cat", subcategory="sub", tool_name="test", tool=tool
)
tool_registry.clear()
assert tool_registry.get_categories() == {}
assert tool_registry.get_tool("test") is None

View File

@@ -0,0 +1,39 @@
"""Unit tests for route filtering utilities."""
from fastmcp.server.openapi import MCPType
from openbb_mcp_server.utils.route_filtering import create_route_maps_from_settings
from openbb_mcp_server.utils.settings import MCPSettings
def test_create_route_maps_from_settings_no_allowed():
settings = MCPSettings(allowed_tool_categories=None)
route_maps = create_route_maps_from_settings(settings)
assert len(route_maps) == 1
assert route_maps[0].methods == ["POST"]
assert route_maps[0].mcp_type == MCPType.EXCLUDE
def test_create_route_maps_from_settings_with_allowed():
settings = MCPSettings(allowed_tool_categories=["equity", "crypto"])
route_maps = create_route_maps_from_settings(settings)
assert len(route_maps) == 3
# Include allowed
assert route_maps[0].pattern == r"^/api/v\d+/(equity|crypto)/.*"
assert route_maps[0].mcp_type == MCPType.TOOL
# Exclude others
assert route_maps[1].pattern == r"^/api/v\d+/(?!(equity|crypto)/).*"
assert route_maps[1].mcp_type == MCPType.EXCLUDE
# POST exclude
assert route_maps[2].methods == ["POST"]
assert route_maps[2].mcp_type == MCPType.EXCLUDE
def test_create_route_maps_from_settings_empty_allowed():
settings = MCPSettings(allowed_tool_categories=[])
route_maps = create_route_maps_from_settings(settings)
assert len(route_maps) == 1
assert route_maps[0].methods == ["POST"]
assert route_maps[0].mcp_type == MCPType.EXCLUDE

View File

@@ -0,0 +1,55 @@
import json
import pytest
from openbb_core.api.rest_api import app as fastapi_app
from openbb_mcp_server.main import MCPSettings, create_mcp_server
@pytest.mark.integration
@pytest.mark.asyncio
async def test_schema_compression():
"""Integration test to verify schema compression effectiveness.
This test verifies that the schema compression in fastmcp is working properly
and that schemas don't grow to unmanageable sizes.
"""
settings = MCPSettings(
name="TestMCP",
describe_responses=False,
default_tool_categories=["all"],
allowed_tool_categories=["news"], # Limit to news tools (runs faster)
enable_tool_discovery=True,
)
# Create MCP server
mcp = create_mcp_server(settings, fastapi_app)
tools = await mcp.get_tools()
assert len(tools) > 0, "No tools found in MCP server"
# Check news tools
for name, tool in list(tools.items()):
input_schema = tool.parameters
input_defs = len(input_schema.get("$defs", {}))
input_chars = len(json.dumps(input_schema))
# Assert with reasonable sizes
assert (
input_chars < 10000
), f"Input schema for {name} too large: {input_chars} chars"
assert input_defs < 10, f"Too many defs in input for {name}: {input_defs}"
# Output schema if exists
if hasattr(tool, "output_schema") and tool.output_schema:
output_schema = tool.output_schema
output_defs = len(output_schema.get("$defs", {}))
output_chars = len(json.dumps(output_schema))
assert (
output_chars < 20000
), f"Output schema for {name} too large: {output_chars} chars"
assert (
output_defs < 20
), f"Too many defs in output for {name}: {output_defs}"

View File

@@ -0,0 +1,27 @@
"""Unit tests for MCPSettings model."""
from openbb_mcp_server.utils.settings import MCPSettings
def test_mcp_settings_defaults():
settings = MCPSettings()
assert settings.name == "OpenBB MCP"
assert settings.default_tool_categories == ["all"]
assert settings.allowed_tool_categories is None
assert settings.enable_tool_discovery is True
assert settings.describe_responses is False
def test_mcp_settings_validation():
settings = MCPSettings(
default_tool_categories="cat1,cat2", allowed_tool_categories="cat3"
)
assert settings.default_tool_categories == ["cat1", "cat2"]
assert settings.allowed_tool_categories == ["cat3"]
def test_mcp_settings_repr():
settings = MCPSettings(name="Test")
repr_str = repr(settings)
assert "MCPSettings" in repr_str
assert "name: Test" in repr_str