From db10c1c8075934d9e8b165e11f75d4de2dc9d8c0 Mon Sep 17 00:00:00 2001 From: rkirscht Date: Fri, 16 May 2025 13:00:06 +0200 Subject: [PATCH 1/8] [add] support for mcp server instructions --- src/mcpo/main.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/mcpo/main.py b/src/mcpo/main.py index 51c033a..dbb5fdf 100644 --- a/src/mcpo/main.py +++ b/src/mcpo/main.py @@ -35,6 +35,10 @@ async def create_dynamic_endpoints(app: FastAPI, api_dependency=None): ) app.version = server_info.version or app.version + instructions = getattr(result, "instructions", None) + if instructions: + app.description = instructions + tools_result = await session.list_tools() tools = tools_result.tools From 438395c768958e73412d8a39e2d1098e7217af9f Mon Sep 17 00:00:00 2001 From: KyleF0X Date: Wed, 28 May 2025 13:06:53 +1000 Subject: [PATCH 2/8] fix: Enable environment variable inheritance for Smithery cloud services authentication --- src/mcpo/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/mcpo/main.py b/src/mcpo/main.py index d05638c..70279c0 100644 --- a/src/mcpo/main.py +++ b/src/mcpo/main.py @@ -103,7 +103,7 @@ async def lifespan(app: FastAPI): server_params = StdioServerParameters( command=command, args=args, - env={**env}, + env={**os.environ, **env}, ) async with stdio_client(server_params) as (reader, writer): From 47888425e26aab12274b2dcdd2d1978c078b552f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=96=87=E9=BE=99?= Date: Thu, 29 May 2025 11:05:20 +0800 Subject: [PATCH 3/8] Print MCP exception stacktrace --- src/mcpo/utils/main.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/src/mcpo/utils/main.py b/src/mcpo/utils/main.py index 4d72815..a5151e6 100644 --- a/src/mcpo/utils/main.py +++ b/src/mcpo/utils/main.py @@ -1,4 +1,5 @@ import json +import traceback from typing import Any, Dict, ForwardRef, List, Optional, Type, Union from fastapi import HTTPException @@ -236,7 +237,9 @@ def get_tool_handler( return final_response except McpError as e: - print(f"MCP Error calling {endpoint_name}: {e.error}") + print( + f"MCP Error calling {endpoint_name}: {traceback.format_exc()}" + ) status_code = MCP_ERROR_TO_HTTP_STATUS.get(e.error.code, 500) raise HTTPException( status_code=status_code, @@ -247,7 +250,9 @@ def get_tool_handler( ), ) except Exception as e: - print(f"Unexpected error calling {endpoint_name}: {e}") + print( + f"Unexpected error calling {endpoint_name}: {traceback.format_exc()}" + ) raise HTTPException( status_code=500, detail={"message": "Unexpected error", "error": str(e)}, @@ -286,7 +291,9 @@ def get_tool_handler( return final_response except McpError as e: - print(f"MCP Error calling {endpoint_name}: {e.error}") + print( + f"MCP Error calling {endpoint_name}: {traceback.format_exc()}" + ) status_code = MCP_ERROR_TO_HTTP_STATUS.get(e.error.code, 500) # Propagate the error received from MCP as an HTTP exception raise HTTPException( @@ -298,7 +305,9 @@ def get_tool_handler( ), ) except Exception as e: - print(f"Unexpected error calling {endpoint_name}: {e}") + print( + f"Unexpected error calling {endpoint_name}: {traceback.format_exc()}" + ) raise HTTPException( status_code=500, detail={"message": "Unexpected error", "error": str(e)}, From dc4c4abe0831685a253516cd87ca84365f838a6e Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Wed, 4 Jun 2025 19:57:13 +0400 Subject: [PATCH 4/8] feat: headers support --- README.md | 12 +++++++++++- src/mcpo/__init__.py | 8 +++++++- src/mcpo/main.py | 23 +++++++++++++++++++++-- 3 files changed, 39 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index c4e9264..042c7e8 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,12 @@ To use an SSE-compatible MCP server, simply specify the server type and endpoint mcpo --port 8000 --api-key "top-secret" --server-type "sse" -- http://127.0.0.1:8001/sse ``` +You can also provide headers for the SSE connection: + +```bash +mcpo --port 8000 --api-key "top-secret" --server-type "sse" --headers '{"Authorization": "Bearer token", "X-Custom-Header": "value"}' -- http://127.0.0.1:8001/sse +``` + To use a Streamable HTTP-compatible MCP server, specify the server type and endpoint: ```bash @@ -93,7 +99,11 @@ Example config.json: }, "mcp_sse": { "type": "sse", // Explicitly define type - "url": "http://127.0.0.1:8001/sse" + "url": "http://127.0.0.1:8001/sse", + "headers": { + "Authorization": "Bearer token", + "X-Custom-Header": "value" + } }, "mcp_streamable_http": { "type": "streamable_http", diff --git a/src/mcpo/__init__.py b/src/mcpo/__init__.py index 23451f9..0b96cc7 100644 --- a/src/mcpo/__init__.py +++ b/src/mcpo/__init__.py @@ -28,7 +28,9 @@ def main( ] = None, strict_auth: Annotated[ Optional[bool], - typer.Option("--strict-auth", help="API key protects all endpoints and documentation"), + typer.Option( + "--strict-auth", help="API key protects all endpoints and documentation" + ), ] = False, env: Annotated[ Optional[List[str]], typer.Option("--env", "-e", help="Environment variables") @@ -61,6 +63,9 @@ def main( path_prefix: Annotated[ Optional[str], typer.Option("--path-prefix", help="URL prefix") ] = None, + headers: Annotated[ + Optional[str], typer.Option("--header", "-H", help="Headers in JSON format") + ] = None, ): server_command = None if not config_path: @@ -131,6 +136,7 @@ def main( ssl_certfile=ssl_certfile, ssl_keyfile=ssl_keyfile, path_prefix=path_prefix, + headers=headers, ) ) diff --git a/src/mcpo/main.py b/src/mcpo/main.py index 845dc85..a248a60 100644 --- a/src/mcpo/main.py +++ b/src/mcpo/main.py @@ -116,7 +116,10 @@ async def lifespan(app: FastAPI): await create_dynamic_endpoints(app, api_dependency=api_dependency) yield if server_type == "sse": - async with sse_client(url=args[0], sse_read_timeout=None) as ( + headers = getattr(app.state, "headers", None) + async with sse_client( + url=args[0], sse_read_timeout=None, headers=headers + ) as ( reader, writer, ): @@ -125,13 +128,15 @@ async def lifespan(app: FastAPI): await create_dynamic_endpoints(app, api_dependency=api_dependency) yield if server_type == "streamablehttp" or server_type == "streamable_http": + headers = getattr(app.state, "headers", None) + # Ensure URL has trailing slash to avoid redirects url = args[0] if not url.endswith("/"): url = f"{url}/" # Connect using streamablehttp_client from the SDK, similar to sse_client - async with streamablehttp_client(url=url) as ( + async with streamablehttp_client(url=url, headers=headers) as ( reader, writer, _, # get_session_id callback not needed for ClientSession @@ -212,6 +217,14 @@ async def run( if api_key and strict_auth: main_app.add_middleware(APIKeyMiddleware, api_key=api_key) + headers = kwargs.get("headers") + if headers and isinstance(headers, str): + try: + headers = json.loads(headers) + except json.JSONDecodeError: + print("Warning: Invalid JSON format for headers. Headers will be ignored.") + headers = None + if server_type == "sse": logger.info( f"Configuring for a single SSE MCP Server with URL {server_command[0]}" @@ -219,6 +232,7 @@ async def run( main_app.state.server_type = "sse" main_app.state.args = server_command[0] # Expects URL as the first element main_app.state.api_dependency = api_dependency + main_app.state.headers = headers elif server_type == "streamablehttp" or server_type == "streamable_http": logger.info( f"Configuring for a single StreamableHTTP MCP Server with URL {server_command[0]}" @@ -226,6 +240,7 @@ async def run( main_app.state.server_type = "streamablehttp" main_app.state.args = server_command[0] # Expects URL as the first element main_app.state.api_dependency = api_dependency + main_app.state.headers = headers elif server_command: # This handles stdio logger.info( f"Configuring for a single Stdio MCP Server with command: {' '.join(server_command)}" @@ -306,6 +321,7 @@ async def run( if server_config_type == "sse" and server_cfg.get("url"): sub_app.state.server_type = "sse" sub_app.state.args = server_cfg["url"] + sub_app.state.headers = server_cfg.get("headers") elif ( server_config_type == "streamablehttp" or server_config_type == "streamable_http" @@ -316,11 +332,14 @@ async def run( url = f"{url}/" sub_app.state.server_type = "streamablehttp" sub_app.state.args = url + sub_app.state.headers = server_cfg.get("headers") + elif not server_config_type and server_cfg.get( "url" ): # Fallback for old SSE config sub_app.state.server_type = "sse" sub_app.state.args = server_cfg["url"] + sub_app.state.headers = server_cfg.get("headers") # Add middleware to protect also documentation and spec if api_key and strict_auth: From 1953ace032fc152b0afcbc349f9ae2d953472709 Mon Sep 17 00:00:00 2001 From: 2underscores Date: Thu, 5 Jun 2025 09:31:12 +1000 Subject: [PATCH 5/8] Fix: Handle parameter names with leading underscores in Pydantic models - Add field aliasing for parameters starting with '__' (e.g., __top, __filter) - Apply fix to both top-level and nested object properties - Use by_alias=True in model_dump to preserve original parameter names for MCP calls - Prevents Pydantic v2 NameError - Enables compatibility with Microsoft 365 MCP server and other OData-based APIs - Fixes #160 --- src/mcpo/utils/main.py | 41 +++++++++++++++++++++++++++++++++++++---- 1 file changed, 37 insertions(+), 4 deletions(-) diff --git a/src/mcpo/utils/main.py b/src/mcpo/utils/main.py index 4d72815..0122971 100644 --- a/src/mcpo/utils/main.py +++ b/src/mcpo/utils/main.py @@ -130,7 +130,23 @@ def _process_schema_property( schema_defs, ) - nested_fields[name] = (nested_type_hint, nested_pydantic_field) + # Handle nested field names with leading underscores + if name.startswith('__'): + clean_name = name.lstrip('_') + # Handle potential naming conflicts + original_clean_name = clean_name + suffix_counter = 1 + while clean_name in nested_properties or clean_name in nested_fields: + clean_name = f"{original_clean_name}_{suffix_counter}" + suffix_counter += 1 + aliased_field = Field( + default=nested_pydantic_field.default, + description=nested_pydantic_field.description, + alias=name + ) + nested_fields[clean_name] = (nested_type_hint, aliased_field) + else: + nested_fields[name] = (nested_type_hint, nested_pydantic_field) if not nested_fields: return Dict[str, Any], pydantic_field @@ -187,8 +203,25 @@ def get_model_fields(form_model_name, properties, required_fields, schema_defs=N is_required, schema_defs, ) - # Use the generated type hint and Field info - model_fields[param_name] = (python_type_hint, pydantic_field_info) + + # Handle parameter names with leading underscores (e.g., __top, __filter) which Pydantic v2 does not allow + if param_name.startswith('__'): + clean_name = param_name.lstrip('_') + # Handle potential naming conflicts + original_clean_name = clean_name + suffix_counter = 1 + while clean_name in properties or clean_name in model_fields: + clean_name = f"{original_clean_name}_{suffix_counter}" + suffix_counter += 1 + aliased_field = Field( + default=pydantic_field_info.default, + description=pydantic_field_info.description, + alias=param_name + ) + model_fields[clean_name] = (python_type_hint, aliased_field) + else: + model_fields[param_name] = (python_type_hint, pydantic_field_info) + return model_fields @@ -210,7 +243,7 @@ def get_tool_handler( endpoint_name: str, FormModel, session: ClientSession ): # Parameterized endpoint async def tool(form_data: FormModel) -> ResponseModel: - args = form_data.model_dump(exclude_none=True) + args = form_data.model_dump(exclude_none=True, by_alias=True) print(f"Calling endpoint: {endpoint_name}, with args: {args}") try: result = await session.call_tool(endpoint_name, arguments=args) From 93297c9bc0fe292a092acf7f351255c7060cd148 Mon Sep 17 00:00:00 2001 From: 2underscores Date: Thu, 5 Jun 2025 22:02:11 +1000 Subject: [PATCH 6/8] Abstracted out alias checks and creation --- src/mcpo/utils/main.py | 56 ++++++++++++++++++++++++++++-------------- 1 file changed, 37 insertions(+), 19 deletions(-) diff --git a/src/mcpo/utils/main.py b/src/mcpo/utils/main.py index 0122971..2baf5bb 100644 --- a/src/mcpo/utils/main.py +++ b/src/mcpo/utils/main.py @@ -48,6 +48,32 @@ def process_tool_response(result: CallToolResult) -> list: return response +def name_needs_alias(name: str) -> bool: + """Check if a field name needs aliasing (for now if it starts with '__').""" + return name.startswith('__') + + +def generate_alias_name(original_name: str, existing_names: set) -> str: + """ + Generate an alias field name by stripping unwanted chars, and avoiding conflicts with existing names. + + Args: + original_name: The original field name (should start with '__') + existing_names: Set of existing names to avoid conflicts with + + Returns: + An alias name that doesn't conflict with existing names + """ + alias_name = original_name.lstrip('_') + # Handle potential naming conflicts + original_alias_name = alias_name + suffix_counter = 1 + while alias_name in existing_names: + alias_name = f"{original_alias_name}_{suffix_counter}" + suffix_counter += 1 + return alias_name + + def _process_schema_property( _model_cache: Dict[str, Type], prop_schema: Dict[str, Any], @@ -130,21 +156,15 @@ def _process_schema_property( schema_defs, ) - # Handle nested field names with leading underscores - if name.startswith('__'): - clean_name = name.lstrip('_') - # Handle potential naming conflicts - original_clean_name = clean_name - suffix_counter = 1 - while clean_name in nested_properties or clean_name in nested_fields: - clean_name = f"{original_clean_name}_{suffix_counter}" - suffix_counter += 1 + if name_needs_alias(name): + other_names = set().union(nested_properties, nested_fields, _model_cache) + alias_name = generate_alias_name(name, other_names) aliased_field = Field( default=nested_pydantic_field.default, description=nested_pydantic_field.description, alias=name ) - nested_fields[clean_name] = (nested_type_hint, aliased_field) + nested_fields[alias_name] = (nested_type_hint, aliased_field) else: nested_fields[name] = (nested_type_hint, nested_pydantic_field) @@ -205,20 +225,18 @@ def get_model_fields(form_model_name, properties, required_fields, schema_defs=N ) # Handle parameter names with leading underscores (e.g., __top, __filter) which Pydantic v2 does not allow - if param_name.startswith('__'): - clean_name = param_name.lstrip('_') - # Handle potential naming conflicts - original_clean_name = clean_name - suffix_counter = 1 - while clean_name in properties or clean_name in model_fields: - clean_name = f"{original_clean_name}_{suffix_counter}" - suffix_counter += 1 + if name_needs_alias(param_name): + print(f"DEBUG: Handling underscore parameter: {param_name}") + other_names = set().union(properties, model_fields, _model_cache) + alias_name = generate_alias_name(param_name, other_names) + print(f"DEBUG: Clean name for {param_name} is {alias_name}") aliased_field = Field( default=pydantic_field_info.default, description=pydantic_field_info.description, alias=param_name ) - model_fields[clean_name] = (python_type_hint, aliased_field) + # Use the generated type hint and Field info + model_fields[alias_name] = (python_type_hint, aliased_field) else: model_fields[param_name] = (python_type_hint, pydantic_field_info) From 0d93fef2791eb45299af5e2fb5504e0093e6c350 Mon Sep 17 00:00:00 2001 From: 2underscores Date: Thu, 5 Jun 2025 22:19:36 +1000 Subject: [PATCH 7/8] rm debug logs --- src/mcpo/utils/main.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/mcpo/utils/main.py b/src/mcpo/utils/main.py index 2baf5bb..06d1886 100644 --- a/src/mcpo/utils/main.py +++ b/src/mcpo/utils/main.py @@ -226,10 +226,8 @@ def get_model_fields(form_model_name, properties, required_fields, schema_defs=N # Handle parameter names with leading underscores (e.g., __top, __filter) which Pydantic v2 does not allow if name_needs_alias(param_name): - print(f"DEBUG: Handling underscore parameter: {param_name}") other_names = set().union(properties, model_fields, _model_cache) alias_name = generate_alias_name(param_name, other_names) - print(f"DEBUG: Clean name for {param_name} is {alias_name}") aliased_field = Field( default=pydantic_field_info.default, description=pydantic_field_info.description, From 919c18719c7719fe7dbd3a9ef4a152dde5cb0354 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Fri, 6 Jun 2025 21:03:04 +0400 Subject: [PATCH 8/8] doc: changelog --- CHANGELOG.md | 12 ++++++++++++ LICENSE | 2 +- pyproject.toml | 2 +- 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e80665..89ac3a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,18 @@ All notable changes to this project will be documented in this file. The format is based on Keep a Changelog, and this project adheres to Semantic Versioning. +## [0.0.15] - 2025-06-06 + +### Added + +- šŸ”ā€Æ**Support for Custom Headers in SSE and Streamable Http MCP Connections**: You can now pass custom HTTP headers (e.g., for authentication tokens or trace IDs) when connecting to SSE or streamable_http servers—enabling seamless integration with remote APIs that require secure or contextual headers. +- šŸ“˜ā€Æ**MCP Server Instructions Exposure**: mcpo now detects and exposes instructions output by MCP tools, bringing descriptive setup guidelines and usage help directly into the OpenAPI schema—so users, UIs, and LLM agents can better understand tool capabilities with zero additional config. +- šŸ§Ŗā€Æ**MCP Exception Stacktrace Printing During Failures**: When a connected MCP server raises an internal error, mcpo now displays the detailed stacktrace from the tool directly in the logs—making debugging on failure dramatically easier for developers and MLops teams working on complex flows. + +### Fixed + +- šŸ§½ā€Æ**Corrected Handling of Underscore Prefix Parameters in Pydantic Modes**: Parameters with leading underscores (e.g. _token) now work correctly without conflict or omission in auto-generated schemas—eliminating validation issues and improving compatibility with tools relying on such parameter naming conventions. + ## [0.0.14] - 2025-05-11 ### Added diff --git a/LICENSE b/LICENSE index 9c6652a..c40e866 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2025 Timothy Jaeryang Baek +Copyright (c) 2025 Timothy Jaeryang Baek (Open WebUI) Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/pyproject.toml b/pyproject.toml index 1563752..b908789 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "mcpo" -version = "0.0.14" +version = "0.0.15" description = "A simple, secure MCP-to-OpenAPI proxy server" authors = [ { name = "Timothy Jaeryang Baek", email = "tim@openwebui.com" }