mirror of
https://github.com/OpenBB-finance/OpenBB.git
synced 2026-10-27 12:40:35 +00:00
[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:
129
openbb_platform/extensions/mcp_server/README.md
vendored
129
openbb_platform/extensions/mcp_server/README.md
vendored
@@ -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`.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
118
openbb_platform/extensions/mcp_server/poetry.lock
generated
vendored
118
openbb_platform/extensions/mcp_server/poetry.lock
generated
vendored
@@ -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"
|
||||
|
||||
@@ -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"]
|
||||
|
||||
9
openbb_platform/extensions/mcp_server/tests/__init__.py
Normal file
9
openbb_platform/extensions/mcp_server/tests/__init__.py
Normal 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)
|
||||
101
openbb_platform/extensions/mcp_server/tests/test_config.py
Normal file
101
openbb_platform/extensions/mcp_server/tests/test_config.py
Normal 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
|
||||
89
openbb_platform/extensions/mcp_server/tests/test_main.py
Normal file
89
openbb_platform/extensions/mcp_server/tests/test_main.py
Normal 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)
|
||||
140
openbb_platform/extensions/mcp_server/tests/test_registry.py
Normal file
140
openbb_platform/extensions/mcp_server/tests/test_registry.py
Normal 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
|
||||
@@ -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
|
||||
55
openbb_platform/extensions/mcp_server/tests/test_schemas.py
Normal file
55
openbb_platform/extensions/mcp_server/tests/test_schemas.py
Normal 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}"
|
||||
27
openbb_platform/extensions/mcp_server/tests/test_settings.py
Normal file
27
openbb_platform/extensions/mcp_server/tests/test_settings.py
Normal 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
|
||||
Reference in New Issue
Block a user