From 4063d352fdbe52f1dd109546e1bdafebc1f047a3 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Sun, 6 Apr 2025 18:32:30 -0700 Subject: [PATCH 01/14] fix: --env option --- src/mcpo/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/mcpo/__init__.py b/src/mcpo/__init__.py index 6109673..30aa42e 100644 --- a/src/mcpo/__init__.py +++ b/src/mcpo/__init__.py @@ -77,7 +77,7 @@ def main( env_dict = {} if env: for var in env: - key, value = env.split("=", 1) + key, value = var.split("=", 1) env_dict[key] = value # Set environment variables From 0897e541169be55d6e124ba13f593d77c0bf07b2 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Sun, 6 Apr 2025 18:33:56 -0700 Subject: [PATCH 02/14] refac --- src/mcpo/__init__.py | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/src/mcpo/__init__.py b/src/mcpo/__init__.py index 30aa42e..e46737e 100644 --- a/src/mcpo/__init__.py +++ b/src/mcpo/__init__.py @@ -74,15 +74,18 @@ def main( f"Starting MCP OpenAPI Proxy on {host}:{port} with command: {' '.join(server_command)}" ) - env_dict = {} - if env: - for var in env: - key, value = var.split("=", 1) - env_dict[key] = value + try: + env_dict = {} + if env: + for var in env: + key, value = var.split("=", 1) + env_dict[key] = value - # Set environment variables - for key, value in env_dict.items(): - os.environ[key] = value + # Set environment variables + for key, value in env_dict.items(): + os.environ[key] = value + except Exception as e: + pass # Whatever the prefix is, make sure it starts and ends with a / if path_prefix is None: From a6b5d5e9b086c41fe0469bd64764a5330a1438d8 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Sun, 6 Apr 2025 19:02:59 -0700 Subject: [PATCH 03/14] refac --- src/mcpo/main.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/mcpo/main.py b/src/mcpo/main.py index 9d7131b..9e8ab7d 100644 --- a/src/mcpo/main.py +++ b/src/mcpo/main.py @@ -96,8 +96,11 @@ async def create_dynamic_endpoints(app: FastAPI, api_dependency=None): def make_endpoint_func( endpoint_name: str, FormModel, session: ClientSession ): # Parameterized endpoint + async def tool(form_data: FormModel): args = form_data.model_dump(exclude_none=True) + print(f"Calling endpoint: {endpoint_name}, with args: {args}") + result = await session.call_tool(endpoint_name, arguments=args) return process_tool_response(result) @@ -110,6 +113,7 @@ async def create_dynamic_endpoints(app: FastAPI, api_dependency=None): endpoint_name: str, session: ClientSession ): # Parameterless endpoint async def tool(): # No parameters + print(f"Calling endpoint: {endpoint_name}, with no args") result = await session.call_tool( endpoint_name, arguments={} ) # Empty dict From 292059ee5e3aaf7455c2f972e3923feee0439318 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Tue, 8 Apr 2025 19:01:16 -0700 Subject: [PATCH 04/14] refac: server path url in docs --- src/mcpo/main.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/mcpo/main.py b/src/mcpo/main.py index 9e8ab7d..ca79307 100644 --- a/src/mcpo/main.py +++ b/src/mcpo/main.py @@ -218,7 +218,7 @@ async def run( for server_name, server_cfg in mcp_servers.items(): sub_app = FastAPI( title=f"{server_name}", - description=f"{server_name} MCP Server\n\n- [back to tool list](http://{host}:{port}/docs)", + description=f"{server_name} MCP Server\n\n- [back to tool list](/docs)", version="1.0", lifespan=lifespan, ) @@ -237,9 +237,7 @@ async def run( sub_app.state.api_dependency = api_dependency main_app.mount(f"{path_prefix}{server_name}", sub_app) - main_app.description += ( - f"\n - [{server_name}](http://{host}:{port}/{server_name}/docs)" - ) + main_app.description += f"\n - [{server_name}](/{server_name}/docs)" else: raise ValueError("You must provide either server_command or config.") From 9cbfcd49b9ce0a98f5d4133d1b95fe8ea012faba Mon Sep 17 00:00:00 2001 From: bmen25124 Date: Wed, 9 Apr 2025 19:18:05 +0300 Subject: [PATCH 05/14] Resolve "object" and "array" types --- README.md | 20 +++++ src/mcpo/main.py | 106 ++++++++++++++++++------ tests/test_main.py | 201 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 301 insertions(+), 26 deletions(-) create mode 100644 tests/test_main.py diff --git a/README.md b/README.md index acb3604..41355ac 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,26 @@ Each with a dedicated OpenAPI schema and proxy handler. Access full schema UI at - Python 3.8+ - uv (optional, but highly recommended for performance + packaging) +## 🛠️ Development & Testing + +To contribute or run tests locally: + +1. **Set up the environment:** + ```bash + # Clone the repository + git clone https://github.com/open-webui/mcpo.git + cd mcpo + + # Install dependencies (including dev dependencies) + uv sync --dev + ``` + +2. **Run tests:** + ```bash + pytest + ``` + + ## 🪪 License MIT diff --git a/src/mcpo/main.py b/src/mcpo/main.py index ca79307..acc679f 100644 --- a/src/mcpo/main.py +++ b/src/mcpo/main.py @@ -1,36 +1,91 @@ import json import os from contextlib import AsyncExitStack, asynccontextmanager -from typing import Dict, Any, Optional +from typing import Dict, Any, Optional, List, Type, Union, ForwardRef import uvicorn -from fastapi import FastAPI, Body, Depends +from fastapi import FastAPI, Depends from fastapi.middleware.cors import CORSMiddleware from mcp import ClientSession, StdioServerParameters, types from mcp.client.stdio import stdio_client from mcp.types import CallToolResult from mcpo.utils.auth import get_verify_api_key -from pydantic import create_model +from pydantic import create_model, Field +from pydantic.fields import FieldInfo from starlette.routing import Mount -def get_python_type(param_type: str): - if param_type == "string": - return str - elif param_type == "integer": - return int - elif param_type == "boolean": - return bool - elif param_type == "number": - return float - elif param_type == "object": - return Dict[str, Any] - elif param_type == "array": - return list +_model_cache: Dict[str, Type] = {} + +def _process_schema_property( + prop_schema: Dict[str, Any], + model_name_prefix: str, + prop_name: str, + is_required: bool, +) -> tuple[Union[Type, List, ForwardRef, Any], FieldInfo]: + """ + Recursively processes a schema property to determine its Python type hint + and Pydantic Field definition. + + Returns: + A tuple containing (python_type_hint, pydantic_field). + The pydantic_field contains default value and description. + """ + prop_type = prop_schema.get("type") + prop_desc = prop_schema.get("description", "") + default_value = ... if is_required else prop_schema.get("default", None) + pydantic_field = Field(default=default_value, description=prop_desc) + + if prop_type == "object": + nested_properties = prop_schema.get("properties", {}) + nested_required = prop_schema.get("required", []) + nested_fields = {} + + nested_model_name = f"{model_name_prefix}_{prop_name}_model".replace("__", "_").rstrip('_') + + if nested_model_name in _model_cache: + return _model_cache[nested_model_name], pydantic_field + + for name, schema in nested_properties.items(): + is_nested_required = name in nested_required + nested_type_hint, nested_pydantic_field = _process_schema_property( + schema, nested_model_name, name, is_nested_required + ) + + nested_fields[name] = (nested_type_hint, nested_pydantic_field) + + if not nested_fields: + return Dict[str, Any], pydantic_field + + NestedModel = create_model(nested_model_name, **nested_fields) + _model_cache[nested_model_name] = NestedModel + + return NestedModel, pydantic_field + + elif prop_type == "array": + items_schema = prop_schema.get("items") + if not items_schema: + # Default to list of anything if items schema is missing + return List[Any], pydantic_field + + # Recursively determine the type of items in the array + item_type_hint, _ = _process_schema_property( + items_schema, f"{model_name_prefix}_{prop_name}", "item", False # Items aren't required at this level + ) + list_type_hint = List[item_type_hint] + return list_type_hint, pydantic_field + + elif prop_type == "string": + return str, pydantic_field + elif prop_type == "integer": + return int, pydantic_field + elif prop_type == "boolean": + return bool, pydantic_field + elif prop_type == "number": + return float, pydantic_field else: - return str # Fallback - # Expand as needed. PRs welcome! + return Any, pydantic_field def process_tool_response(result: CallToolResult) -> list: @@ -80,18 +135,17 @@ async def create_dynamic_endpoints(app: FastAPI, api_dependency=None): required_fields = schema.get("required", []) properties = schema.get("properties", {}) + form_model_name = f"{endpoint_name}_form_model" for param_name, param_schema in properties.items(): - param_type = param_schema.get("type", "string") - param_desc = param_schema.get("description", "") - python_type = get_python_type(param_type) - default_value = ... if param_name in required_fields else None - model_fields[param_name] = ( - python_type, - Body(default_value, description=param_desc), + is_required = param_name in required_fields + python_type_hint, pydantic_field_info = _process_schema_property( + param_schema, form_model_name, param_name, is_required ) + # Use the generated type hint and Field info + model_fields[param_name] = (python_type_hint, pydantic_field_info) if model_fields: - FormModel = create_model(f"{endpoint_name}_form_model", **model_fields) + FormModel = create_model(form_model_name, **model_fields) def make_endpoint_func( endpoint_name: str, FormModel, session: ClientSession diff --git a/tests/test_main.py b/tests/test_main.py new file mode 100644 index 0000000..2c73743 --- /dev/null +++ b/tests/test_main.py @@ -0,0 +1,201 @@ +import pytest +from pydantic import BaseModel, Field +from typing import Any, List, Dict + +from src.mcpo.main import _process_schema_property, _model_cache + + +@pytest.fixture(autouse=True) +def clear_model_cache(): + _model_cache.clear() + yield + _model_cache.clear() + + +def test_process_simple_string_required(): + schema = {"type": "string", "description": "A simple string"} + expected_type = str + expected_field = Field(default=..., description="A simple string") + result_type, result_field = _process_schema_property(schema, "test", "prop", True) + assert result_type == expected_type + assert result_field.default == expected_field.default + assert result_field.description == expected_field.description + + +def test_process_simple_integer_optional(): + schema = {"type": "integer", "default": 10} + expected_type = int + expected_field = Field(default=10, description="") + result_type, result_field = _process_schema_property(schema, "test", "prop", False) + assert result_type == expected_type + assert result_field.default == expected_field.default + assert result_field.description == expected_field.description + + +def test_process_simple_boolean_optional_no_default(): + schema = {"type": "boolean"} + expected_type = bool + expected_field = Field( + default=None, description="" + ) # Default is None if not required and no default specified + result_type, result_field = _process_schema_property(schema, "test", "prop", False) + assert result_type == expected_type + assert result_field.default == expected_field.default + assert result_field.description == expected_field.description + + +def test_process_simple_number(): + schema = {"type": "number"} + expected_type = float + expected_field = Field(default=..., description="") + result_type, result_field = _process_schema_property(schema, "test", "prop", True) + assert result_type == expected_type + assert result_field.default == expected_field.default + + +def test_process_unknown_type(): + schema = {"type": "unknown"} + expected_type = Any + expected_field = Field(default=..., description="") + result_type, result_field = _process_schema_property(schema, "test", "prop", True) + assert result_type == expected_type + assert result_field.default == expected_field.default + + +def test_process_array_of_strings(): + schema = {"type": "array", "items": {"type": "string"}} + expected_type = List[str] + expected_field = Field(default=..., description="") + result_type, result_field = _process_schema_property(schema, "test", "prop", True) + assert result_type == expected_type + assert result_field.default == expected_field.default + + +def test_process_array_of_any_missing_items(): + schema = {"type": "array"} # Missing "items" + expected_type = List[Any] + expected_field = Field(default=None, description="") + result_type, result_field = _process_schema_property(schema, "test", "prop", False) + assert result_type == expected_type + assert result_field.default == expected_field.default + + +def test_process_simple_object(): + schema = { + "type": "object", + "properties": {"name": {"type": "string"}, "age": {"type": "integer"}}, + "required": ["name"], + } + expected_field = Field(default=..., description="") + result_type, result_field = _process_schema_property(schema, "test", "prop", True) + + assert result_field.default == expected_field.default + assert result_field.description == expected_field.description + assert issubclass(result_type, BaseModel) # Check if it's a Pydantic model + assert "test_prop_model" in _model_cache # Check caching + + # Check fields of the generated model + model_fields = result_type.model_fields + assert "name" in model_fields + assert model_fields["name"].annotation is str + assert model_fields["name"].is_required() + + assert "age" in model_fields + assert model_fields["age"].annotation is int + assert not model_fields["age"].is_required() + assert model_fields["age"].default is None # Optional without default + + +def test_process_nested_object(): + schema = { + "type": "object", + "properties": { + "user": { + "type": "object", + "properties": {"id": {"type": "integer"}}, + "required": ["id"], + } + }, + "required": ["user"], + } + expected_field = Field(default=..., description="") + result_type, result_field = _process_schema_property( + schema, "test", "outer_prop", True + ) + + assert result_field.default == expected_field.default + assert "test_outer_prop_model" in _model_cache + assert issubclass(result_type, BaseModel) + + outer_model_fields = result_type.model_fields + assert "user" in outer_model_fields + assert outer_model_fields["user"].is_required() + + nested_model_type = outer_model_fields["user"].annotation + assert issubclass(nested_model_type, BaseModel) + assert "test_outer_prop_model_user_model" in _model_cache + + nested_model_fields = nested_model_type.model_fields + assert "id" in nested_model_fields + assert nested_model_fields["id"].annotation is int + assert nested_model_fields["id"].is_required() + + +def test_process_array_of_objects(): + schema = { + "type": "array", + "items": { + "type": "object", + "properties": {"item_id": {"type": "string"}}, + "required": ["item_id"], + }, + } + expected_field = Field(default=..., description="") + result_type, result_field = _process_schema_property(schema, "test", "prop", True) + + assert result_field.default == expected_field.default + assert str(result_type).startswith("typing.List[") # Check it's a List + assert "test_prop_item_model" in _model_cache + + # Get the inner type from List[...] + item_model_type = result_type.__args__[0] + assert issubclass(item_model_type, BaseModel) + + item_model_fields = item_model_type.model_fields + assert "item_id" in item_model_fields + assert item_model_fields["item_id"].annotation is str + assert item_model_fields["item_id"].is_required() + + +def test_process_empty_object(): + schema = {"type": "object", "properties": {}} + expected_type = Dict[str, Any] # Should default to Dict[str, Any] if no properties + expected_field = Field(default=..., description="") + result_type, result_field = _process_schema_property(schema, "test", "prop", True) + assert result_type == expected_type + assert result_field.default == expected_field.default + + +def test_model_caching(): + schema = { + "type": "object", + "properties": {"id": {"type": "integer"}}, + "required": ["id"], + } + # First call + result_type1, _ = _process_schema_property(schema, "cache_test", "obj1", True) + model_name = "cache_test_obj1_model" + assert model_name in _model_cache + assert _model_cache[model_name] == result_type1 + + # Second call with same structure but different prefix/prop name (should generate new) + result_type2, _ = _process_schema_property(schema, "cache_test", "obj2", True) + model_name2 = "cache_test_obj2_model" + assert model_name2 in _model_cache + assert _model_cache[model_name2] == result_type2 + assert result_type1 != result_type2 # Different models + + # Third call identical to the first (should return cached model) + result_type3, _ = _process_schema_property(schema, "cache_test", "obj1", True) + assert result_type3 == result_type1 # Should be the same cached object + assert len(_model_cache) == 2 # Only two unique models created From 637dc48281b59d1a8190160bc86fedbc31ad31c4 Mon Sep 17 00:00:00 2001 From: bmen25124 Date: Wed, 9 Apr 2025 20:01:29 +0300 Subject: [PATCH 06/14] Updated dev dependencies --- pyproject.toml | 5 +++++ uv.lock | 51 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index b53613f..c42a880 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,3 +24,8 @@ mcpo = "mcpo:app" [build-system] requires = ["hatchling"] build-backend = "hatchling.build" + +[dependency-groups] +dev = [ + "pytest>=8.3.5", +] diff --git a/uv.lock b/uv.lock index 5ceb911..5272581 100644 --- a/uv.lock +++ b/uv.lock @@ -1,4 +1,5 @@ version = 1 +revision = 1 requires-python = ">=3.11" [[package]] @@ -261,6 +262,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 }, ] +[[package]] +name = "iniconfig" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050 }, +] + [[package]] name = "markdown-it-py" version = "3.0.0" @@ -307,6 +317,11 @@ dependencies = [ { name = "uvicorn" }, ] +[package.dev-dependencies] +dev = [ + { name = "pytest" }, +] + [package.metadata] requires-dist = [ { name = "click", specifier = ">=8.1.8" }, @@ -319,6 +334,9 @@ requires-dist = [ { name = "uvicorn", specifier = ">=0.34.0" }, ] +[package.metadata.requires-dev] +dev = [{ name = "pytest", specifier = ">=8.3.5" }] + [[package]] name = "mdurl" version = "0.1.2" @@ -328,6 +346,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979 }, ] +[[package]] +name = "packaging" +version = "24.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/63/68dbb6eb2de9cb10ee4c9c14a0148804425e13c4fb20d61cce69f53106da/packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f", size = 163950 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/ef/eb23f262cca3c0c4eb7ab1933c3b1f03d021f2c48f54763065b6f0e321be/packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759", size = 65451 }, +] + [[package]] name = "passlib" version = "1.7.4" @@ -342,6 +369,15 @@ bcrypt = [ { name = "bcrypt" }, ] +[[package]] +name = "pluggy" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/2d/02d4312c973c6050a18b314a5ad0b3210edb65a906f868e31c111dede4a6/pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1", size = 67955 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556 }, +] + [[package]] name = "pycparser" version = "2.22" @@ -467,6 +503,21 @@ crypto = [ { name = "cryptography" }, ] +[[package]] +name = "pytest" +version = "8.3.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/3c/c9d525a414d506893f0cd8a8d0de7706446213181570cdbd766691164e40/pytest-8.3.5.tar.gz", hash = "sha256:f4efe70cc14e511565ac476b57c279e12a855b11f48f212af1080ef2263d3845", size = 1450891 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/30/3d/64ad57c803f1fa1e963a7946b6e0fea4a70df53c1a7fed304586539c2bac/pytest-8.3.5-py3-none-any.whl", hash = "sha256:c69214aa47deac29fad6c2a4f590b9c4a9fdb16a403176fe154b79c0b4d4d820", size = 343634 }, +] + [[package]] name = "python-dotenv" version = "1.1.0" From 6e05f1c56fc6bdf20f6f89c45a11701d36a9a20c Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Thu, 10 Apr 2025 09:27:41 -0700 Subject: [PATCH 07/14] feat: --env-path support --- pyproject.toml | 1 + src/mcpo/__init__.py | 11 ++++++++++- uv.lock | 3 ++- 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index c42a880..d7c4ecf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,6 +14,7 @@ dependencies = [ "passlib[bcrypt]>=1.7.4", "pydantic>=2.11.1", "pyjwt[crypto]>=2.10.1", + "python-dotenv>=1.1.0", "typer>=0.15.2", "uvicorn>=0.34.0", ] diff --git a/src/mcpo/__init__.py b/src/mcpo/__init__.py index e46737e..9d31bd2 100644 --- a/src/mcpo/__init__.py +++ b/src/mcpo/__init__.py @@ -2,7 +2,7 @@ import sys import asyncio import typer import os - +from dotenv import load_dotenv from typing_extensions import Annotated from typing import Optional, List @@ -29,6 +29,10 @@ def main( env: Annotated[ Optional[List[str]], typer.Option("--env", "-e", help="Environment variables") ] = None, + env_path: Annotated[ + Optional[str], + typer.Option("--env-path", help="Path to environment variables file"), + ] = None, config: Annotated[ Optional[str], typer.Option("--config", "-c", help="Config file path") ] = None, @@ -81,6 +85,11 @@ def main( key, value = var.split("=", 1) env_dict[key] = value + if env_path: + # Load environment variables from the specified file + load_dotenv(env_path) + env_dict.update(dict(os.environ)) + # Set environment variables for key, value in env_dict.items(): os.environ[key] = value diff --git a/uv.lock b/uv.lock index 5272581..eb08903 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,4 @@ version = 1 -revision = 1 requires-python = ">=3.11" [[package]] @@ -313,6 +312,7 @@ dependencies = [ { name = "passlib", extra = ["bcrypt"] }, { name = "pydantic" }, { name = "pyjwt", extra = ["crypto"] }, + { name = "python-dotenv" }, { name = "typer" }, { name = "uvicorn" }, ] @@ -330,6 +330,7 @@ requires-dist = [ { name = "passlib", extras = ["bcrypt"], specifier = ">=1.7.4" }, { name = "pydantic", specifier = ">=2.11.1" }, { name = "pyjwt", extras = ["crypto"], specifier = ">=2.10.1" }, + { name = "python-dotenv", specifier = ">=1.1.0" }, { name = "typer", specifier = ">=0.15.2" }, { name = "uvicorn", specifier = ">=0.34.0" }, ] From 3941420630e5bf33d9ddbf02d70cad926db1a251 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Thu, 10 Apr 2025 09:38:08 -0700 Subject: [PATCH 08/14] refac --- src/mcpo/main.py | 155 +++++------------------------------------ src/mcpo/utils/main.py | 154 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 172 insertions(+), 137 deletions(-) create mode 100644 src/mcpo/utils/main.py diff --git a/src/mcpo/main.py b/src/mcpo/main.py index acc679f..c866f30 100644 --- a/src/mcpo/main.py +++ b/src/mcpo/main.py @@ -6,111 +6,19 @@ from typing import Dict, Any, Optional, List, Type, Union, ForwardRef import uvicorn from fastapi import FastAPI, Depends from fastapi.middleware.cors import CORSMiddleware -from mcp import ClientSession, StdioServerParameters, types -from mcp.client.stdio import stdio_client -from mcp.types import CallToolResult - -from mcpo.utils.auth import get_verify_api_key -from pydantic import create_model, Field -from pydantic.fields import FieldInfo from starlette.routing import Mount -_model_cache: Dict[str, Type] = {} - -def _process_schema_property( - prop_schema: Dict[str, Any], - model_name_prefix: str, - prop_name: str, - is_required: bool, -) -> tuple[Union[Type, List, ForwardRef, Any], FieldInfo]: - """ - Recursively processes a schema property to determine its Python type hint - and Pydantic Field definition. - - Returns: - A tuple containing (python_type_hint, pydantic_field). - The pydantic_field contains default value and description. - """ - prop_type = prop_schema.get("type") - prop_desc = prop_schema.get("description", "") - default_value = ... if is_required else prop_schema.get("default", None) - pydantic_field = Field(default=default_value, description=prop_desc) - - if prop_type == "object": - nested_properties = prop_schema.get("properties", {}) - nested_required = prop_schema.get("required", []) - nested_fields = {} - - nested_model_name = f"{model_name_prefix}_{prop_name}_model".replace("__", "_").rstrip('_') - - if nested_model_name in _model_cache: - return _model_cache[nested_model_name], pydantic_field - - for name, schema in nested_properties.items(): - is_nested_required = name in nested_required - nested_type_hint, nested_pydantic_field = _process_schema_property( - schema, nested_model_name, name, is_nested_required - ) - - nested_fields[name] = (nested_type_hint, nested_pydantic_field) - - if not nested_fields: - return Dict[str, Any], pydantic_field - - NestedModel = create_model(nested_model_name, **nested_fields) - _model_cache[nested_model_name] = NestedModel - - return NestedModel, pydantic_field - - elif prop_type == "array": - items_schema = prop_schema.get("items") - if not items_schema: - # Default to list of anything if items schema is missing - return List[Any], pydantic_field - - # Recursively determine the type of items in the array - item_type_hint, _ = _process_schema_property( - items_schema, f"{model_name_prefix}_{prop_name}", "item", False # Items aren't required at this level - ) - list_type_hint = List[item_type_hint] - return list_type_hint, pydantic_field - - elif prop_type == "string": - return str, pydantic_field - elif prop_type == "integer": - return int, pydantic_field - elif prop_type == "boolean": - return bool, pydantic_field - elif prop_type == "number": - return float, pydantic_field - else: - return Any, pydantic_field +from mcp import ClientSession, StdioServerParameters, types +from mcp.client.stdio import stdio_client -def process_tool_response(result: CallToolResult) -> list: - """Universal response processor for all tool endpoints""" - response = [] - for content in result.content: - if isinstance(content, types.TextContent): - text = content.text - if isinstance(text, str): - try: - text = json.loads(text) - except json.JSONDecodeError: - pass - response.append(text) - elif isinstance(content, types.ImageContent): - image_data = f"data:{content.mimeType};base64,{content.data}" - response.append(image_data) - elif isinstance(content, types.EmbeddedResource): - # TODO: Handle embedded resources - response.append("Embedded resource not supported yet.") - return response +from mcpo.utils.main import get_model_fields, get_tool_handler +from mcpo.utils.auth import get_verify_api_key async def create_dynamic_endpoints(app: FastAPI, api_dependency=None): - session = app.state.session + session: ClientSession = app.state.session if not session: raise ValueError("Session is not initialized in the app state.") @@ -131,51 +39,20 @@ async def create_dynamic_endpoints(app: FastAPI, api_dependency=None): endpoint_description = tool.description schema = tool.inputSchema - model_fields = {} required_fields = schema.get("required", []) properties = schema.get("properties", {}) form_model_name = f"{endpoint_name}_form_model" - for param_name, param_schema in properties.items(): - is_required = param_name in required_fields - python_type_hint, pydantic_field_info = _process_schema_property( - param_schema, form_model_name, param_name, is_required - ) - # Use the generated type hint and Field info - model_fields[param_name] = (python_type_hint, pydantic_field_info) - if model_fields: - FormModel = create_model(form_model_name, **model_fields) + model_fields = get_model_fields( + form_model_name, + properties, + required_fields, + ) - def make_endpoint_func( - endpoint_name: str, FormModel, session: ClientSession - ): # Parameterized endpoint - - async def tool(form_data: FormModel): - args = form_data.model_dump(exclude_none=True) - print(f"Calling endpoint: {endpoint_name}, with args: {args}") - - result = await session.call_tool(endpoint_name, arguments=args) - return process_tool_response(result) - - return tool - - tool_handler = make_endpoint_func(endpoint_name, FormModel, session) - else: - - def make_endpoint_func_no_args( - endpoint_name: str, session: ClientSession - ): # Parameterless endpoint - async def tool(): # No parameters - print(f"Calling endpoint: {endpoint_name}, with no args") - result = await session.call_tool( - endpoint_name, arguments={} - ) # Empty dict - return process_tool_response(result) # Same processor - - return tool - - tool_handler = make_endpoint_func_no_args(endpoint_name, session) + tool_handler = get_tool_handler( + session, endpoint_name, form_model_name, model_fields + ) app.post( f"/{endpoint_name}", @@ -229,11 +106,13 @@ async def run( # MCP Config config_path = kwargs.get("config") server_command = kwargs.get("server_command") + name = kwargs.get("name") or "MCP OpenAPI Proxy" description = ( kwargs.get("description") or "Automatically generated API from MCP Tool Schemas" ) version = kwargs.get("version") or "1.0" + ssl_certfile = kwargs.get("ssl_certfile") ssl_keyfile = kwargs.get("ssl_keyfile") path_prefix = kwargs.get("path_prefix") or "/" @@ -256,7 +135,6 @@ async def run( ) if server_command: - main_app.state.command = server_command[0] main_app.state.args = server_command[1:] main_app.state.env = os.environ.copy() @@ -265,9 +143,11 @@ async def run( elif config_path: with open(config_path, "r") as f: config_data = json.load(f) + mcp_servers = config_data.get("mcpServers", {}) if not mcp_servers: raise ValueError("No 'mcpServers' found in config file.") + main_app.description += "\n\n- **available tools**:" for server_name, server_cfg in mcp_servers.items(): sub_app = FastAPI( @@ -290,6 +170,7 @@ async def run( sub_app.state.env = {**os.environ, **server_cfg.get("env", {})} sub_app.state.api_dependency = api_dependency + main_app.mount(f"{path_prefix}{server_name}", sub_app) main_app.description += f"\n - [{server_name}](/{server_name}/docs)" else: diff --git a/src/mcpo/utils/main.py b/src/mcpo/utils/main.py new file mode 100644 index 0000000..a958e0b --- /dev/null +++ b/src/mcpo/utils/main.py @@ -0,0 +1,154 @@ +from typing import Any, Dict, List, Type, Union, ForwardRef +from pydantic import create_model, Field +from pydantic.fields import FieldInfo +from mcp import ClientSession, types +from mcp.types import CallToolResult + +import json + + +def process_tool_response(result: CallToolResult) -> list: + """Universal response processor for all tool endpoints""" + response = [] + for content in result.content: + if isinstance(content, types.TextContent): + text = content.text + if isinstance(text, str): + try: + text = json.loads(text) + except json.JSONDecodeError: + pass + response.append(text) + elif isinstance(content, types.ImageContent): + image_data = f"data:{content.mimeType};base64,{content.data}" + response.append(image_data) + elif isinstance(content, types.EmbeddedResource): + # TODO: Handle embedded resources + response.append("Embedded resource not supported yet.") + return response + + +def _process_schema_property( + _model_cache: Dict[str, Type], + prop_schema: Dict[str, Any], + model_name_prefix: str, + prop_name: str, + is_required: bool, +) -> tuple[Union[Type, List, ForwardRef, Any], FieldInfo]: + """ + Recursively processes a schema property to determine its Python type hint + and Pydantic Field definition. + + Returns: + A tuple containing (python_type_hint, pydantic_field). + The pydantic_field contains default value and description. + """ + prop_type = prop_schema.get("type") + prop_desc = prop_schema.get("description", "") + default_value = ... if is_required else prop_schema.get("default", None) + pydantic_field = Field(default=default_value, description=prop_desc) + + if prop_type == "object": + nested_properties = prop_schema.get("properties", {}) + nested_required = prop_schema.get("required", []) + nested_fields = {} + + nested_model_name = f"{model_name_prefix}_{prop_name}_model".replace( + "__", "_" + ).rstrip("_") + + if nested_model_name in _model_cache: + return _model_cache[nested_model_name], pydantic_field + + for name, schema in nested_properties.items(): + is_nested_required = name in nested_required + nested_type_hint, nested_pydantic_field = _process_schema_property( + schema, nested_model_name, name, is_nested_required + ) + + nested_fields[name] = (nested_type_hint, nested_pydantic_field) + + if not nested_fields: + return Dict[str, Any], pydantic_field + + NestedModel = create_model(nested_model_name, **nested_fields) + _model_cache[nested_model_name] = NestedModel + + return NestedModel, pydantic_field + + elif prop_type == "array": + items_schema = prop_schema.get("items") + if not items_schema: + # Default to list of anything if items schema is missing + return List[Any], pydantic_field + + # Recursively determine the type of items in the array + item_type_hint, _ = _process_schema_property( + items_schema, + f"{model_name_prefix}_{prop_name}", + "item", + False, # Items aren't required at this level + ) + list_type_hint = List[item_type_hint] + return list_type_hint, pydantic_field + + elif prop_type == "string": + return str, pydantic_field + elif prop_type == "integer": + return int, pydantic_field + elif prop_type == "boolean": + return bool, pydantic_field + elif prop_type == "number": + return float, pydantic_field + else: + return Any, pydantic_field + + +def get_model_fields(form_model_name, properties, required_fields): + model_fields = {} + + _model_cache: Dict[str, Type] = {} + + for param_name, param_schema in properties.items(): + is_required = param_name in required_fields + python_type_hint, pydantic_field_info = _process_schema_property( + _model_cache, param_schema, form_model_name, param_name, is_required + ) + # Use the generated type hint and Field info + model_fields[param_name] = (python_type_hint, pydantic_field_info) + return model_fields + + +def get_tool_handler(session, endpoint_name, form_model_name, model_fields): + if model_fields: + FormModel = create_model(form_model_name, **model_fields) + + def make_endpoint_func( + endpoint_name: str, FormModel, session: ClientSession + ): # Parameterized endpoint + + async def tool(form_data: FormModel): + args = form_data.model_dump(exclude_none=True) + print(f"Calling endpoint: {endpoint_name}, with args: {args}") + + result = await session.call_tool(endpoint_name, arguments=args) + return process_tool_response(result) + + return tool + + tool_handler = make_endpoint_func(endpoint_name, FormModel, session) + else: + + def make_endpoint_func_no_args( + endpoint_name: str, session: ClientSession + ): # Parameterless endpoint + async def tool(): # No parameters + print(f"Calling endpoint: {endpoint_name}, with no args") + result = await session.call_tool( + endpoint_name, arguments={} + ) # Empty dict + return process_tool_response(result) # Same processor + + return tool + + tool_handler = make_endpoint_func_no_args(endpoint_name, session) From 2c2c46eb302657ff3406e3b7e1288e73c706b150 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Thu, 10 Apr 2025 09:40:59 -0700 Subject: [PATCH 09/14] refac --- src/mcpo/utils/main.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/mcpo/utils/main.py b/src/mcpo/utils/main.py index a958e0b..e5b25a6 100644 --- a/src/mcpo/utils/main.py +++ b/src/mcpo/utils/main.py @@ -152,3 +152,5 @@ def get_tool_handler(session, endpoint_name, form_model_name, model_fields): return tool tool_handler = make_endpoint_func_no_args(endpoint_name, session) + + return tool_handler From d46803d427a1c29852e85ac01845aa06ffa2eff0 Mon Sep 17 00:00:00 2001 From: bmen25124 Date: Thu, 10 Apr 2025 20:45:07 +0300 Subject: [PATCH 10/14] Improved error handling, fixed couple of errors --- src/mcpo/main.py | 8 +++-- src/mcpo/utils/main.py | 70 ++++++++++++++++++++++++++++++++++-------- tests/test_main.py | 63 +++++++++++++++++++++++++------------ 3 files changed, 107 insertions(+), 34 deletions(-) diff --git a/src/mcpo/main.py b/src/mcpo/main.py index c866f30..df9b540 100644 --- a/src/mcpo/main.py +++ b/src/mcpo/main.py @@ -1,7 +1,7 @@ import json import os from contextlib import AsyncExitStack, asynccontextmanager -from typing import Dict, Any, Optional, List, Type, Union, ForwardRef +from typing import Optional import uvicorn from fastapi import FastAPI, Depends @@ -9,11 +9,11 @@ from fastapi.middleware.cors import CORSMiddleware from starlette.routing import Mount -from mcp import ClientSession, StdioServerParameters, types +from mcp import ClientSession, StdioServerParameters from mcp.client.stdio import stdio_client -from mcpo.utils.main import get_model_fields, get_tool_handler +from mcpo.utils.main import get_model_fields, get_tool_handler, ToolResponse from mcpo.utils.auth import get_verify_api_key @@ -58,6 +58,8 @@ async def create_dynamic_endpoints(app: FastAPI, api_dependency=None): f"/{endpoint_name}", summary=endpoint_name.replace("_", " ").title(), description=endpoint_description, + response_model=ToolResponse, + response_model_exclude_none=True, dependencies=[Depends(api_dependency)] if api_dependency else [], )(tool_handler) diff --git a/src/mcpo/utils/main.py b/src/mcpo/utils/main.py index e5b25a6..5992f98 100644 --- a/src/mcpo/utils/main.py +++ b/src/mcpo/utils/main.py @@ -1,12 +1,18 @@ -from typing import Any, Dict, List, Type, Union, ForwardRef -from pydantic import create_model, Field +from typing import Any, Dict, List, Type, Union, ForwardRef, Optional +from pydantic import BaseModel, create_model, Field from pydantic.fields import FieldInfo from mcp import ClientSession, types from mcp.types import CallToolResult +from mcp.shared.exceptions import McpError import json +class ToolResponse(BaseModel): + response: Optional[Any] = None + errorMessage: Optional[str] = None + errorData: Optional[Any] = None + def process_tool_response(result: CallToolResult) -> list: """Universal response processor for all tool endpoints""" response = [] @@ -63,7 +69,7 @@ def _process_schema_property( for name, schema in nested_properties.items(): is_nested_required = name in nested_required nested_type_hint, nested_pydantic_field = _process_schema_property( - schema, nested_model_name, name, is_nested_required + _model_cache, schema, nested_model_name, name, is_nested_required ) nested_fields[name] = (nested_type_hint, nested_pydantic_field) @@ -84,6 +90,7 @@ def _process_schema_property( # Recursively determine the type of items in the array item_type_hint, _ = _process_schema_property( + _model_cache, items_schema, f"{model_name_prefix}_{prop_name}", "item", @@ -126,13 +133,31 @@ def get_tool_handler(session, endpoint_name, form_model_name, model_fields): def make_endpoint_func( endpoint_name: str, FormModel, session: ClientSession ): # Parameterized endpoint - - async def tool(form_data: FormModel): + async def tool(form_data: FormModel) -> ToolResponse: args = form_data.model_dump(exclude_none=True) print(f"Calling endpoint: {endpoint_name}, with args: {args}") + try: + result = await session.call_tool(endpoint_name, arguments=args) - result = await session.call_tool(endpoint_name, arguments=args) - return process_tool_response(result) + if result.isError: + errorMessage = "Unknown tool execution error" + if result.content and isinstance(result.content[0], types.TextContent): + errorMessage = result.content[0].text + return ToolResponse(errorMessage=errorMessage) + + response_data = process_tool_response(result) + final_response = response_data[0] if len(response_data) == 1 else response_data + return ToolResponse(response=final_response) + + except McpError as e: + print(f"MCP Error calling {endpoint_name}: {e.error}") + return ToolResponse( + errorMessage=e.error.message, + errorData=e.error.data, + ) + except Exception as e: + print(f"Unexpected error calling {endpoint_name}: {e}") + return ToolResponse(errorMessage=f"An unexpected internal error occurred: {e}") return tool @@ -142,12 +167,33 @@ def get_tool_handler(session, endpoint_name, form_model_name, model_fields): def make_endpoint_func_no_args( endpoint_name: str, session: ClientSession ): # Parameterless endpoint - async def tool(): # No parameters + async def tool() -> ToolResponse: # No parameters print(f"Calling endpoint: {endpoint_name}, with no args") - result = await session.call_tool( - endpoint_name, arguments={} - ) # Empty dict - return process_tool_response(result) # Same processor + try: + result = await session.call_tool( + endpoint_name, arguments={} + ) # Empty dict + + if result.isError: + error_message = "Unknown tool execution error" + if result.content and isinstance(result.content[0], types.TextContent): + error_message = result.content[0].text + return ToolResponse(errorMessage=error_message) + + response_data = process_tool_response(result) + final_response = response_data[0] if len(response_data) == 1 else response_data + return ToolResponse(response=final_response) + + except McpError as e: + print(f"MCP Error calling {endpoint_name}: {e.error}") + # Propagate the error received from MCP + return ToolResponse( + errorMessage=e.error.message, + errorData=e.error.data, + ) + except Exception as e: + print(f"Unexpected error calling {endpoint_name}: {e}") + return ToolResponse(errorMessage=f"An unexpected internal error occurred: {e}") return tool diff --git a/tests/test_main.py b/tests/test_main.py index 2c73743..94d4454 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -2,7 +2,10 @@ import pytest from pydantic import BaseModel, Field from typing import Any, List, Dict -from src.mcpo.main import _process_schema_property, _model_cache +from src.mcpo.utils.main import _process_schema_property + + +_model_cache = {} @pytest.fixture(autouse=True) @@ -16,7 +19,9 @@ def test_process_simple_string_required(): schema = {"type": "string", "description": "A simple string"} expected_type = str expected_field = Field(default=..., description="A simple string") - result_type, result_field = _process_schema_property(schema, "test", "prop", True) + result_type, result_field = _process_schema_property( + _model_cache, schema, "test", "prop", True + ) assert result_type == expected_type assert result_field.default == expected_field.default assert result_field.description == expected_field.description @@ -26,7 +31,9 @@ def test_process_simple_integer_optional(): schema = {"type": "integer", "default": 10} expected_type = int expected_field = Field(default=10, description="") - result_type, result_field = _process_schema_property(schema, "test", "prop", False) + result_type, result_field = _process_schema_property( + _model_cache, schema, "test", "prop", False + ) assert result_type == expected_type assert result_field.default == expected_field.default assert result_field.description == expected_field.description @@ -38,7 +45,9 @@ def test_process_simple_boolean_optional_no_default(): expected_field = Field( default=None, description="" ) # Default is None if not required and no default specified - result_type, result_field = _process_schema_property(schema, "test", "prop", False) + result_type, result_field = _process_schema_property( + _model_cache, schema, "test", "prop", False + ) assert result_type == expected_type assert result_field.default == expected_field.default assert result_field.description == expected_field.description @@ -48,7 +57,9 @@ def test_process_simple_number(): schema = {"type": "number"} expected_type = float expected_field = Field(default=..., description="") - result_type, result_field = _process_schema_property(schema, "test", "prop", True) + result_type, result_field = _process_schema_property( + _model_cache, schema, "test", "prop", True + ) assert result_type == expected_type assert result_field.default == expected_field.default @@ -57,7 +68,9 @@ def test_process_unknown_type(): schema = {"type": "unknown"} expected_type = Any expected_field = Field(default=..., description="") - result_type, result_field = _process_schema_property(schema, "test", "prop", True) + result_type, result_field = _process_schema_property( + _model_cache, schema, "test", "prop", True + ) assert result_type == expected_type assert result_field.default == expected_field.default @@ -66,7 +79,9 @@ def test_process_array_of_strings(): schema = {"type": "array", "items": {"type": "string"}} expected_type = List[str] expected_field = Field(default=..., description="") - result_type, result_field = _process_schema_property(schema, "test", "prop", True) + result_type, result_field = _process_schema_property( + _model_cache, schema, "test", "prop", True + ) assert result_type == expected_type assert result_field.default == expected_field.default @@ -75,7 +90,9 @@ def test_process_array_of_any_missing_items(): schema = {"type": "array"} # Missing "items" expected_type = List[Any] expected_field = Field(default=None, description="") - result_type, result_field = _process_schema_property(schema, "test", "prop", False) + result_type, result_field = _process_schema_property( + _model_cache, schema, "test", "prop", False + ) assert result_type == expected_type assert result_field.default == expected_field.default @@ -87,12 +104,13 @@ def test_process_simple_object(): "required": ["name"], } expected_field = Field(default=..., description="") - result_type, result_field = _process_schema_property(schema, "test", "prop", True) + result_type, result_field = _process_schema_property( + _model_cache, schema, "test", "prop", True + ) assert result_field.default == expected_field.default assert result_field.description == expected_field.description assert issubclass(result_type, BaseModel) # Check if it's a Pydantic model - assert "test_prop_model" in _model_cache # Check caching # Check fields of the generated model model_fields = result_type.model_fields @@ -120,11 +138,10 @@ def test_process_nested_object(): } expected_field = Field(default=..., description="") result_type, result_field = _process_schema_property( - schema, "test", "outer_prop", True + _model_cache, schema, "test", "outer_prop", True ) assert result_field.default == expected_field.default - assert "test_outer_prop_model" in _model_cache assert issubclass(result_type, BaseModel) outer_model_fields = result_type.model_fields @@ -133,7 +150,6 @@ def test_process_nested_object(): nested_model_type = outer_model_fields["user"].annotation assert issubclass(nested_model_type, BaseModel) - assert "test_outer_prop_model_user_model" in _model_cache nested_model_fields = nested_model_type.model_fields assert "id" in nested_model_fields @@ -151,11 +167,12 @@ def test_process_array_of_objects(): }, } expected_field = Field(default=..., description="") - result_type, result_field = _process_schema_property(schema, "test", "prop", True) + result_type, result_field = _process_schema_property( + _model_cache, schema, "test", "prop", True + ) assert result_field.default == expected_field.default assert str(result_type).startswith("typing.List[") # Check it's a List - assert "test_prop_item_model" in _model_cache # Get the inner type from List[...] item_model_type = result_type.__args__[0] @@ -171,7 +188,9 @@ def test_process_empty_object(): schema = {"type": "object", "properties": {}} expected_type = Dict[str, Any] # Should default to Dict[str, Any] if no properties expected_field = Field(default=..., description="") - result_type, result_field = _process_schema_property(schema, "test", "prop", True) + result_type, result_field = _process_schema_property( + _model_cache, schema, "test", "prop", True + ) assert result_type == expected_type assert result_field.default == expected_field.default @@ -183,19 +202,25 @@ def test_model_caching(): "required": ["id"], } # First call - result_type1, _ = _process_schema_property(schema, "cache_test", "obj1", True) + result_type1, _ = _process_schema_property( + _model_cache, schema, "cache_test", "obj1", True + ) model_name = "cache_test_obj1_model" assert model_name in _model_cache assert _model_cache[model_name] == result_type1 # Second call with same structure but different prefix/prop name (should generate new) - result_type2, _ = _process_schema_property(schema, "cache_test", "obj2", True) + result_type2, _ = _process_schema_property( + _model_cache, schema, "cache_test", "obj2", True + ) model_name2 = "cache_test_obj2_model" assert model_name2 in _model_cache assert _model_cache[model_name2] == result_type2 assert result_type1 != result_type2 # Different models # Third call identical to the first (should return cached model) - result_type3, _ = _process_schema_property(schema, "cache_test", "obj1", True) + result_type3, _ = _process_schema_property( + _model_cache, schema, "cache_test", "obj1", True + ) assert result_type3 == result_type1 # Should be the same cached object assert len(_model_cache) == 2 # Only two unique models created From 9c7d91cf3a37b97a9cbf6b00d642080736219c5b Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Thu, 10 Apr 2025 11:27:49 -0700 Subject: [PATCH 11/14] fix --- src/mcpo/utils/main.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/mcpo/utils/main.py b/src/mcpo/utils/main.py index e5b25a6..4e3244b 100644 --- a/src/mcpo/utils/main.py +++ b/src/mcpo/utils/main.py @@ -63,7 +63,7 @@ def _process_schema_property( for name, schema in nested_properties.items(): is_nested_required = name in nested_required nested_type_hint, nested_pydantic_field = _process_schema_property( - schema, nested_model_name, name, is_nested_required + _model_cache, schema, nested_model_name, name, is_nested_required ) nested_fields[name] = (nested_type_hint, nested_pydantic_field) @@ -84,6 +84,7 @@ def _process_schema_property( # Recursively determine the type of items in the array item_type_hint, _ = _process_schema_property( + _model_cache, items_schema, f"{model_name_prefix}_{prop_name}", "item", From 8f8cd406dca22f97ec0b74ddc388b8f43dba5fdf Mon Sep 17 00:00:00 2001 From: bmen25124 Date: Thu, 10 Apr 2025 21:29:29 +0300 Subject: [PATCH 12/14] Added proper HTTP exceptions --- src/mcpo/utils/main.py | 72 ++++++++++++++++++++++++++++++++---------- 1 file changed, 55 insertions(+), 17 deletions(-) diff --git a/src/mcpo/utils/main.py b/src/mcpo/utils/main.py index 5992f98..ea53e35 100644 --- a/src/mcpo/utils/main.py +++ b/src/mcpo/utils/main.py @@ -2,11 +2,20 @@ from typing import Any, Dict, List, Type, Union, ForwardRef, Optional from pydantic import BaseModel, create_model, Field from pydantic.fields import FieldInfo from mcp import ClientSession, types -from mcp.types import CallToolResult +from fastapi import HTTPException +from mcp.types import CallToolResult, PARSE_ERROR, INVALID_REQUEST, METHOD_NOT_FOUND, INVALID_PARAMS, INTERNAL_ERROR from mcp.shared.exceptions import McpError import json +MCP_ERROR_TO_HTTP_STATUS = { + PARSE_ERROR: 400, + INVALID_REQUEST: 400, + METHOD_NOT_FOUND: 404, + INVALID_PARAMS: 422, + INTERNAL_ERROR: 500, +} + class ToolResponse(BaseModel): response: Optional[Any] = None @@ -140,10 +149,18 @@ def get_tool_handler(session, endpoint_name, form_model_name, model_fields): result = await session.call_tool(endpoint_name, arguments=args) if result.isError: - errorMessage = "Unknown tool execution error" - if result.content and isinstance(result.content[0], types.TextContent): - errorMessage = result.content[0].text - return ToolResponse(errorMessage=errorMessage) + error_message = "Unknown tool execution error" + error_data = None # Initialize error_data + if result.content: + if isinstance(result.content[0], types.TextContent): + error_message = result.content[0].text + detail = {"message": error_message} + if error_data is not None: + detail["data"] = error_data + raise HTTPException( + status_code=500, + detail=detail, + ) response_data = process_tool_response(result) final_response = response_data[0] if len(response_data) == 1 else response_data @@ -151,13 +168,21 @@ def get_tool_handler(session, endpoint_name, form_model_name, model_fields): except McpError as e: print(f"MCP Error calling {endpoint_name}: {e.error}") - return ToolResponse( - errorMessage=e.error.message, - errorData=e.error.data, + status_code = MCP_ERROR_TO_HTTP_STATUS.get(e.error.code, 500) + raise HTTPException( + status_code=status_code, + detail=( + {"message": e.error.message, "data": e.error.data} + if e.error.data is not None + else {"message": e.error.message} + ), ) except Exception as e: print(f"Unexpected error calling {endpoint_name}: {e}") - return ToolResponse(errorMessage=f"An unexpected internal error occurred: {e}") + raise HTTPException( + status_code=500, + detail={"message": "Unexpected error", "error": str(e)}, + ) return tool @@ -176,9 +201,14 @@ def get_tool_handler(session, endpoint_name, form_model_name, model_fields): if result.isError: error_message = "Unknown tool execution error" - if result.content and isinstance(result.content[0], types.TextContent): - error_message = result.content[0].text - return ToolResponse(errorMessage=error_message) + if result.content: + if isinstance(result.content[0], types.TextContent): + error_message = result.content[0].text + detail = {"message": error_message} + raise HTTPException( + status_code=500, + detail=detail, + ) response_data = process_tool_response(result) final_response = response_data[0] if len(response_data) == 1 else response_data @@ -186,14 +216,22 @@ def get_tool_handler(session, endpoint_name, form_model_name, model_fields): except McpError as e: print(f"MCP Error calling {endpoint_name}: {e.error}") - # Propagate the error received from MCP - return ToolResponse( - errorMessage=e.error.message, - errorData=e.error.data, + status_code = MCP_ERROR_TO_HTTP_STATUS.get(e.error.code, 500) + # Propagate the error received from MCP as an HTTP exception + raise HTTPException( + status_code=status_code, + detail=( + {"message": e.error.message, "data": e.error.data} + if e.error.data is not None + else {"message": e.error.message} + ), ) except Exception as e: print(f"Unexpected error calling {endpoint_name}: {e}") - return ToolResponse(errorMessage=f"An unexpected internal error occurred: {e}") + raise HTTPException( + status_code=500, + detail={"message": "Unexpected error", "error": str(e)}, + ) return tool From 566c308d05fd0830fe4593d513a8362575980995 Mon Sep 17 00:00:00 2001 From: bmen25124 Date: Thu, 10 Apr 2025 21:35:46 +0300 Subject: [PATCH 13/14] Removed ToolResponse --- src/mcpo/main.py | 3 +-- src/mcpo/utils/main.py | 17 ++++++----------- 2 files changed, 7 insertions(+), 13 deletions(-) diff --git a/src/mcpo/main.py b/src/mcpo/main.py index df9b540..2fe872e 100644 --- a/src/mcpo/main.py +++ b/src/mcpo/main.py @@ -13,7 +13,7 @@ from mcp import ClientSession, StdioServerParameters from mcp.client.stdio import stdio_client -from mcpo.utils.main import get_model_fields, get_tool_handler, ToolResponse +from mcpo.utils.main import get_model_fields, get_tool_handler from mcpo.utils.auth import get_verify_api_key @@ -58,7 +58,6 @@ async def create_dynamic_endpoints(app: FastAPI, api_dependency=None): f"/{endpoint_name}", summary=endpoint_name.replace("_", " ").title(), description=endpoint_description, - response_model=ToolResponse, response_model_exclude_none=True, dependencies=[Depends(api_dependency)] if api_dependency else [], )(tool_handler) diff --git a/src/mcpo/utils/main.py b/src/mcpo/utils/main.py index ea53e35..29a71dd 100644 --- a/src/mcpo/utils/main.py +++ b/src/mcpo/utils/main.py @@ -1,5 +1,5 @@ -from typing import Any, Dict, List, Type, Union, ForwardRef, Optional -from pydantic import BaseModel, create_model, Field +from typing import Any, Dict, List, Type, Union, ForwardRef +from pydantic import create_model, Field from pydantic.fields import FieldInfo from mcp import ClientSession, types from fastapi import HTTPException @@ -17,11 +17,6 @@ MCP_ERROR_TO_HTTP_STATUS = { } -class ToolResponse(BaseModel): - response: Optional[Any] = None - errorMessage: Optional[str] = None - errorData: Optional[Any] = None - def process_tool_response(result: CallToolResult) -> list: """Universal response processor for all tool endpoints""" response = [] @@ -142,7 +137,7 @@ def get_tool_handler(session, endpoint_name, form_model_name, model_fields): def make_endpoint_func( endpoint_name: str, FormModel, session: ClientSession ): # Parameterized endpoint - async def tool(form_data: FormModel) -> ToolResponse: + async def tool(form_data: FormModel): args = form_data.model_dump(exclude_none=True) print(f"Calling endpoint: {endpoint_name}, with args: {args}") try: @@ -164,7 +159,7 @@ def get_tool_handler(session, endpoint_name, form_model_name, model_fields): response_data = process_tool_response(result) final_response = response_data[0] if len(response_data) == 1 else response_data - return ToolResponse(response=final_response) + return final_response except McpError as e: print(f"MCP Error calling {endpoint_name}: {e.error}") @@ -192,7 +187,7 @@ def get_tool_handler(session, endpoint_name, form_model_name, model_fields): def make_endpoint_func_no_args( endpoint_name: str, session: ClientSession ): # Parameterless endpoint - async def tool() -> ToolResponse: # No parameters + async def tool(): # No parameters print(f"Calling endpoint: {endpoint_name}, with no args") try: result = await session.call_tool( @@ -212,7 +207,7 @@ def get_tool_handler(session, endpoint_name, form_model_name, model_fields): response_data = process_tool_response(result) final_response = response_data[0] if len(response_data) == 1 else response_data - return ToolResponse(response=final_response) + return final_response except McpError as e: print(f"MCP Error calling {endpoint_name}: {e.error}") From bce2e10426803576600c1fe1b9d8c848caebe090 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Thu, 10 Apr 2025 13:16:02 -0700 Subject: [PATCH 14/14] chore: bump --- CHANGELOG.md | 12 ++++++++++++ pyproject.toml | 2 +- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 864eb67..8487915 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,18 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.0.10] - 2025-04-10 + +### Added + +- 📦 **Support for --env-path to Load Environment Variables from File**: Use the new --env-path flag to securely pass environment variables via a .env-style file—making it easier than ever to manage secrets and config without cluttering your CLI or exposing sensitive data. +- 🧪 **Enhanced Support for Nested Object and Array Types in OpenAPI Schema**: Tools with complex input/output structures (e.g., JSON payloads with arrays or nested fields) are now correctly interpreted and exposed with accurate OpenAPI documentation—making form-based testing in the UI smoother and integrations far more predictable. +- 🛑 **Smart HTTP Exceptions for Better Debugging**: Clear, structured HTTP error responses are now automatically returned for bad requests or internal tool errors—helping users immediately understand what went wrong without digging through raw traces. + +### Fixed + +- 🪛 **Fixed --env Flag Behavior for Inline Environment Variables**: Resolved issues where the --env CLI flag silently failed or misbehaved—environment injection is now consistent and reliable whether passed inline with --env or via --env-path. + ## [0.0.9] - 2025-04-06 ### Added diff --git a/pyproject.toml b/pyproject.toml index d7c4ecf..758d4ff 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "mcpo" -version = "0.0.9" +version = "0.0.10" description = "A simple, secure MCP-to-OpenAPI proxy server" authors = [ { name = "Timothy Jaeryang Baek", email = "tim@openwebui.com" }