Merge pull request #140 from open-webui/dev
Some checks failed
Release / release (push) Has been cancelled
Create and publish Docker images with specific build args / build-main-image (linux/amd64) (push) Has been cancelled
Create and publish Docker images with specific build args / build-main-image (linux/arm64) (push) Has been cancelled
Create and publish Docker images with specific build args / merge-main-images (push) Has been cancelled

0.0.15
This commit is contained in:
Tim Jaeryang Baek 2025-06-06 21:03:13 +04:00 committed by GitHub
commit 4758d30f6c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 124 additions and 15 deletions

View File

@ -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

View File

@ -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

View File

@ -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",

View File

@ -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" }

View File

@ -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,
)
)

View File

@ -36,6 +36,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
@ -104,7 +108,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):
@ -113,7 +117,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,
):
@ -122,13 +129,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
@ -209,6 +218,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]}"
@ -216,6 +233,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]}"
@ -223,6 +241,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)}"
@ -303,6 +322,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"
@ -313,11 +333,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:

View File

@ -1,4 +1,5 @@
import json
import traceback
from typing import Any, Dict, ForwardRef, List, Optional, Type, Union
from fastapi import HTTPException
@ -48,6 +49,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,7 +157,17 @@ def _process_schema_property(
schema_defs,
)
nested_fields[name] = (nested_type_hint, nested_pydantic_field)
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[alias_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 +224,21 @@ 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 name_needs_alias(param_name):
other_names = set().union(properties, model_fields, _model_cache)
alias_name = generate_alias_name(param_name, other_names)
aliased_field = Field(
default=pydantic_field_info.default,
description=pydantic_field_info.description,
alias=param_name
)
# 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)
return model_fields
@ -210,7 +260,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)
@ -236,7 +286,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 +299,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 +340,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 +354,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)},