Skip to content
Open
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
9 changes: 7 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,12 +111,13 @@ Example config.json:
"mcpServers": {
"memory": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-memory"]
"args": ["-y", "@modelcontextprotocol/server-memory"],
"cwd": "/tmp"
},
"time": {
"command": "uvx",
"args": ["mcp-server-time", "--local-timezone=America/New_York"],
"disabledTools": ["convert_time"] // Disable specific tools if needed
"disabled_tools": ["convert_time"] // Disable specific tools if needed
},
"mcp_sse": {
"type": "sse", // Explicitly define type
Expand All @@ -134,6 +135,10 @@ Example config.json:
}
```

Use `disabled_tools` to hide specific MCP tools from the generated OpenAPI
server. The legacy camelCase key `disabledTools` is still accepted for
backwards compatibility.

Each tool will be accessible under its own unique route, e.g.:
- http://localhost:8000/memory
- http://localhost:8000/time
Expand Down
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,13 @@ authors = [
{ name = "Timothy Jaeryang Baek", email = "tim@openwebui.com" }
]
readme = "README.md"
license = "MIT"
license-files = ["LICENSE"]
requires-python = ">=3.11"
dependencies = [
"click>=8.1.8",
"fastapi>=0.115.12",
"fastapi-offline>=1.7.6",
"mcp>=1.17.0",
"mcp[cli]>=1.17.0",
"passlib[bcrypt]>=1.7.4",
Expand Down
22 changes: 18 additions & 4 deletions src/mcpo/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,9 @@
from urllib.parse import urljoin

import uvicorn
from fastapi import Depends, FastAPI
from fastapi import Depends
from fastapi.middleware.cors import CORSMiddleware
from fastapi_offline import FastAPIOffline
from starlette.routing import Mount

from mcp import ClientSession, StdioServerParameters
Expand Down Expand Up @@ -66,6 +67,7 @@ def __init__(
command: Optional[str],
args: List[str],
env: Dict[str, str],
cwd: Optional[str],
headers: Optional[Dict[str, str]],
connection_timeout: Optional[int],
auth_provider: Optional[Any] = None,
Expand All @@ -74,6 +76,7 @@ def __init__(
self.command = command
self.args = args
self.env = env
self.cwd = cwd
self.headers = headers
self.connection_timeout = connection_timeout
self.auth_provider = auth_provider
Expand Down Expand Up @@ -168,6 +171,7 @@ def _create_client_context(self):
command=self.command,
args=self.args,
env={**os.environ, **self.env},
cwd=self.cwd,
)
return stdio_client(server_params)
if self.server_type == "sse":
Expand Down Expand Up @@ -201,6 +205,12 @@ def validate_server_config(server_name: str, server_cfg: Dict[str, Any]) -> None
raise ValueError(f"Server '{server_name}' 'command' must be a string")
if server_cfg.get("args") and not isinstance(server_cfg["args"], list):
raise ValueError(f"Server '{server_name}' 'args' must be a list")
if (
"cwd" in server_cfg
and server_cfg["cwd"] is not None
and not isinstance(server_cfg["cwd"], str)
):
raise ValueError(f"Server '{server_name}' 'cwd' must be a string")
elif server_cfg.get("url") and not server_type:
# Fallback for old SSE config without explicit type
pass
Expand Down Expand Up @@ -260,9 +270,9 @@ def create_sub_app(
api_dependency,
connection_timeout,
lifespan,
) -> FastAPI:
) -> FastAPIOffline:
"""Create a sub-application for an MCP server."""
sub_app = FastAPI(
sub_app = FastAPIOffline(
title=f"{server_name}",
description=f"{server_name} MCP Server\n\n- [back to tool list](/docs)",
version="1.0",
Expand All @@ -284,6 +294,7 @@ def create_sub_app(
sub_app.state.command = server_cfg["command"]
sub_app.state.args = server_cfg.get("args", [])
sub_app.state.env = {**os.environ, **server_cfg.get("env", {})}
sub_app.state.cwd = server_cfg.get("cwd")

server_config_type = server_cfg.get("type")
if server_config_type == "sse" and server_cfg.get("url"):
Expand Down Expand Up @@ -578,6 +589,7 @@ async def lifespan(app: FastAPI):
args = getattr(app.state, "args", [])
args = args if isinstance(args, list) else [args]
env = getattr(app.state, "env", {})
cwd = getattr(app.state, "cwd", None)
connection_timeout = getattr(app.state, "connection_timeout", CONNECTION_TIMEOUT)
api_dependency = getattr(app.state, "api_dependency", None)
path_prefix = getattr(app.state, "path_prefix", "/")
Expand Down Expand Up @@ -708,6 +720,7 @@ async def lifespan(app: FastAPI):
command=command,
args=args,
env=env,
cwd=cwd,
headers=headers,
connection_timeout=connection_timeout,
auth_provider=auth_provider,
Expand Down Expand Up @@ -810,7 +823,7 @@ def filter(self, record):
# Create shutdown handler
shutdown_handler = GracefulShutdown()

main_app = FastAPI(
main_app = FastAPIOffline(
title=name,
description=description,
version=version,
Expand Down Expand Up @@ -868,6 +881,7 @@ def filter(self, record):
main_app.state.command = server_command[0]
main_app.state.args = server_command[1:]
main_app.state.env = os.environ.copy()
main_app.state.cwd = None
main_app.state.api_dependency = api_dependency
elif config_path:
logger.info(f"Loading MCP server configurations from: {config_path}")
Expand Down
71 changes: 62 additions & 9 deletions src/mcpo/tests/test_hot_reload.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,19 @@
import pytest
from fastapi import FastAPI

from mcpo.main import load_config, validate_server_config, reload_config_handler
from mcpo.main import (
MCPConnectionManager,
create_sub_app,
lifespan,
load_config,
reload_config_handler,
validate_server_config,
)


def test_validate_server_config_stdio():
"""Test validation of stdio server configuration."""
config = {"command": "echo", "args": ["hello", "world"]}
config = {"command": "echo", "args": ["hello", "world"], "cwd": "/tmp"}
# Should not raise
validate_server_config("test_server", config)

Expand All @@ -32,6 +39,13 @@ def test_validate_server_config_invalid():
validate_server_config("test_server", config)


def test_validate_server_config_invalid_cwd():
"""Test validation fails for a non-string cwd."""
config = {"command": "echo", "cwd": ["not", "a", "path"]}
with pytest.raises(ValueError, match="'cwd' must be a string"):
validate_server_config("test_server", config)


def test_validate_server_config_missing_url():
"""Test validation fails for SSE config missing URL."""
config = {
Expand All @@ -42,23 +56,27 @@ def test_validate_server_config_missing_url():
validate_server_config("test_server", config)


def test_validate_server_config_disabled_tools_valid():
"""Test validation of server configuration with a valid disabledTools."""
config = {"command": "echo", "args": ["hello"], "disabledTools": ["search-web"]}
@pytest.mark.parametrize("disabled_tools_key", ["disabled_tools", "disabledTools"])
def test_validate_server_config_disabled_tools_valid(disabled_tools_key):
"""Test validation of server configuration with valid disabled tools."""
config = {"command": "echo", "args": ["hello"], disabled_tools_key: ["search-web"]}
validate_server_config("test_server", config)


def test_validate_server_config_disabled_tools_invalid():
"""Test validation fails for an invalid disabledTools."""
config = {"command": "echo", "args": ["hello"], "disabledTools": "not-a-list"}
@pytest.mark.parametrize("disabled_tools_key", ["disabled_tools", "disabledTools"])
def test_validate_server_config_disabled_tools_invalid(disabled_tools_key):
"""Test validation fails for invalid disabled tools."""
config = {"command": "echo", "args": ["hello"], disabled_tools_key: "not-a-list"}
with pytest.raises(ValueError, match="'disabledTools' must be a list"):
validate_server_config("test_server", config)


def test_load_config_valid():
"""Test loading a valid config file."""
config_data = {
"mcpServers": {"test_server": {"command": "echo", "args": ["hello"]}}
"mcpServers": {
"test_server": {"command": "echo", "args": ["hello"], "cwd": "/tmp"}
}
}

with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
Expand All @@ -72,6 +90,41 @@ def test_load_config_valid():
os.unlink(config_path)


def test_create_sub_app_stores_stdio_cwd():
"""Test cwd is stored for stdio servers."""
app = create_sub_app(
"test_server",
{"command": "echo", "args": ["hello"], "cwd": "/tmp"},
cors_allow_origins=["*"],
api_key=None,
strict_auth=False,
api_dependency=None,
connection_timeout=None,
lifespan=lifespan,
)

assert app.state.cwd == "/tmp"


def test_connection_manager_passes_stdio_cwd():
"""Test cwd is passed to MCP stdio server parameters."""
manager = MCPConnectionManager(
server_type="stdio",
command="echo",
args=["hello"],
env={},
cwd="/tmp",
headers=None,
connection_timeout=None,
)

with patch("mcpo.main.stdio_client") as mock_stdio_client:
manager._create_client_context()

server_params = mock_stdio_client.call_args.args[0]
assert server_params.cwd == "/tmp"


def test_load_config_invalid_json():
"""Test loading invalid JSON fails."""
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
Expand Down
Loading