mirror of
https://github.com/open-webui/mcpo
synced 2025-06-26 18:26:58 +00:00
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
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:
commit
4758d30f6c
12
CHANGELOG.md
12
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
|
||||
|
||||
2
LICENSE
2
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
|
||||
|
||||
12
README.md
12
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",
|
||||
|
||||
@ -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" }
|
||||
|
||||
@ -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,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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)},
|
||||
|
||||
Loading…
Reference in New Issue
Block a user