Merge pull request #42 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.9
This commit is contained in:
Timothy Jaeryang Baek 2025-04-06 18:24:00 -07:00 committed by GitHub
commit 8c8a35e07e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 324 additions and 53 deletions

165
.github/workflows/docker-build.yaml vendored Normal file
View File

@ -0,0 +1,165 @@
name: Create and publish Docker images with specific build args
on:
workflow_dispatch:
push:
branches:
- main
- dev
tags:
- v*
env:
REGISTRY: ghcr.io
jobs:
build-main-image:
runs-on: ${{ matrix.platform == 'linux/arm64' && 'ubuntu-24.04-arm' || 'ubuntu-latest' }}
permissions:
contents: read
packages: write
strategy:
fail-fast: false
matrix:
platform:
- linux/amd64
- linux/arm64
steps:
# GitHub Packages requires the entire repository name to be in lowercase
# although the repository owner has a lowercase username, this prevents some people from running actions after forking
- name: Set repository and image name to lowercase
run: |
echo "IMAGE_NAME=${IMAGE_NAME,,}" >>${GITHUB_ENV}
echo "FULL_IMAGE_NAME=ghcr.io/${IMAGE_NAME,,}" >>${GITHUB_ENV}
env:
IMAGE_NAME: "${{ github.repository }}"
- name: Prepare
run: |
platform=${{ matrix.platform }}
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to the Container registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata for Docker images (default latest tag)
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.FULL_IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=tag
type=sha,prefix=git-
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
flavor: |
latest=${{ github.ref == 'refs/heads/main' }}
- name: Extract metadata for Docker cache
id: cache-meta
uses: docker/metadata-action@v5
with:
images: ${{ env.FULL_IMAGE_NAME }}
tags: |
type=ref,event=branch
${{ github.ref_type == 'tag' && 'type=raw,value=main' || '' }}
flavor: |
prefix=cache-${{ matrix.platform }}-
latest=false
- name: Build Docker image (latest)
uses: docker/build-push-action@v5
id: build
with:
context: .
push: true
platforms: ${{ matrix.platform }}
labels: ${{ steps.meta.outputs.labels }}
outputs: type=image,name=${{ env.FULL_IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
cache-from: type=registry,ref=${{ steps.cache-meta.outputs.tags }}
cache-to: type=registry,ref=${{ steps.cache-meta.outputs.tags }},mode=max
build-args: |
BUILD_HASH=${{ github.sha }}
- name: Export digest
run: |
mkdir -p /tmp/digests
digest="${{ steps.build.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: digests-main-${{ env.PLATFORM_PAIR }}
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
merge-main-images:
runs-on: ubuntu-latest
needs: [build-main-image]
steps:
# GitHub Packages requires the entire repository name to be in lowercase
# although the repository owner has a lowercase username, this prevents some people from running actions after forking
- name: Set repository and image name to lowercase
run: |
echo "IMAGE_NAME=${IMAGE_NAME,,}" >>${GITHUB_ENV}
echo "FULL_IMAGE_NAME=ghcr.io/${IMAGE_NAME,,}" >>${GITHUB_ENV}
env:
IMAGE_NAME: "${{ github.repository }}"
- name: Download digests
uses: actions/download-artifact@v4
with:
pattern: digests-main-*
path: /tmp/digests
merge-multiple: true
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to the Container registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata for Docker images (default latest tag)
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.FULL_IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=tag
type=sha,prefix=git-
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
flavor: |
latest=${{ github.ref == 'refs/heads/main' }}
- name: Create manifest list and push
working-directory: /tmp/digests
run: |
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf '${{ env.FULL_IMAGE_NAME }}@sha256:%s ' *)
- name: Inspect image
run: |
docker buildx imagetools inspect ${{ env.FULL_IMAGE_NAME }}:${{ steps.meta.outputs.version }}

View File

@ -5,6 +5,14 @@ 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.9] - 2025-04-06
### Added
- 🧭 **Clearer Docs Navigation with Path Awareness**: Optimized the /docs and /[tool]/docs pages to clearly display full endpoint paths when using mcpo --config, making it obvious where each tool is hosted—no more guessing or confusion when running multiple tools under different routes.
- 🛤️ **New --path-prefix Option for Precise Routing Control**: Introduced optional --path-prefix flag allowing you to customize the route prefix for all mounted tools—great for integrating mcpo into existing infrastructures, reverse proxies, or multi-service APIs without route collisions.
- 🐳 **Official Dockerfile for Easy Deployment**: Added a first-party Dockerfile so you can containerize mcpo in seconds—perfect for deploying to production, shipping models with standardized dependencies, and running anywhere with a consistent environment.
## [0.0.8] - 2025-04-03
### Added

42
Dockerfile Normal file
View File

@ -0,0 +1,42 @@
FROM python:3.12-slim-bookworm
# Install uv (from official binary), nodejs, npm, and git
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
RUN apt-get update && apt-get install -y --no-install-recommends \
git \
curl \
ca-certificates \
&& rm -rf /var/lib/apt/lists/*
# Install Node.js and npm via NodeSource
RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
&& apt-get install -y nodejs \
&& rm -rf /var/lib/apt/lists/*
# Confirm npm and node versions (optional debugging info)
RUN node -v && npm -v
# Copy your mcpo source code (assuming in src/mcpo)
COPY . /app
WORKDIR /app
# Create virtual environment explicitly in known location
ENV VIRTUAL_ENV=/app/.venv
RUN uv venv "$VIRTUAL_ENV"
ENV PATH="$VIRTUAL_ENV/bin:$PATH"
# Install mcpo (assuming pyproject.toml is properly configured)
RUN uv pip install . && rm -rf ~/.cache
# Verify mcpo installed correctly
RUN which mcpo
# Expose port (optional but common default)
EXPOSE 8000
# Entrypoint set for easy container invocation
ENTRYPOINT ["mcpo"]
# Default help CMD (can override at runtime)
CMD ["--help"]

View File

@ -40,6 +40,12 @@ pip install mcpo
mcpo --port 8000 --api-key "top-secret" -- your_mcp_server_command
```
You can also run mcpo via Docker with no installation:
```bash
docker run -p 8000:8000 ghcr.io/open-webui/mcpo:main --api-key "top-secret" -- your_mcp_server_command
```
Example:
```bash

View File

@ -1,6 +1,6 @@
[project]
name = "mcpo"
version = "0.0.8"
version = "0.0.9"
description = "A simple, secure MCP-to-OpenAPI proxy server"
authors = [
{ name = "Timothy Jaeryang Baek", email = "tim@openwebui.com" }
@ -12,6 +12,7 @@ dependencies = [
"fastapi>=0.115.12",
"mcp>=1.6.0",
"passlib[bcrypt]>=1.7.4",
"pydantic>=2.11.1",
"pyjwt[crypto]>=2.10.1",
"typer>=0.15.2",
"uvicorn>=0.34.0",

View File

@ -45,7 +45,10 @@ def main(
Optional[str], typer.Option("--ssl-certfile", "-t", help="SSL certfile")
] = None,
ssl_keyfile: Annotated[
Optional[str], typer.Option("--ssl-keyfile", "-k", help="SSL keyfile")
Optional[str], typer.Option("--ssl-keyfile", "-k", help="SSL keyfile")
] = None,
path_prefix: Annotated[
Optional[str], typer.Option("--path-prefix", help="URL prefix")
] = None,
):
server_command = None
@ -81,6 +84,17 @@ def main(
for key, value in env_dict.items():
os.environ[key] = value
# Whatever the prefix is, make sure it starts and ends with a /
if path_prefix is None:
# Set default value
path_prefix = "/"
# if prefix doesn't end with a /, add it
if not path_prefix.endswith("/"):
path_prefix = f"{path_prefix}/"
# if prefix doesn't start with a /, add it
if not path_prefix.startswith("/"):
path_prefix = f"/{path_prefix}"
# Run your async run function from mcpo.main
asyncio.run(
run(
@ -95,6 +109,7 @@ def main(
server_command=server_command,
ssl_certfile=ssl_certfile,
ssl_keyfile=ssl_keyfile,
path_prefix=path_prefix,
)
)

View File

@ -1,18 +1,18 @@
from fastapi import FastAPI, Body, Depends
from fastapi.middleware.cors import CORSMiddleware
from starlette.routing import Mount
from pydantic import create_model
from contextlib import AsyncExitStack, asynccontextmanager
from mcp import ClientSession, StdioServerParameters, types
from mcp.client.stdio import stdio_client
from typing import Dict, Any, Optional
import uvicorn
import json
import os
from contextlib import AsyncExitStack, asynccontextmanager
from typing import Dict, Any, Optional
import uvicorn
from fastapi import FastAPI, Body, 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 starlette.routing import Mount
def get_python_type(param_type: str):
@ -33,6 +33,27 @@ def get_python_type(param_type: str):
# Expand as needed. PRs welcome!
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
async def create_dynamic_endpoints(app: FastAPI, api_dependency=None):
session = app.state.session
if not session:
@ -55,10 +76,11 @@ async def create_dynamic_endpoints(app: FastAPI, api_dependency=None):
endpoint_description = tool.description
schema = tool.inputSchema
# Build Pydantic model
model_fields = {}
required_fields = schema.get("required", [])
for param_name, param_schema in schema["properties"].items():
properties = schema.get("properties", {})
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)
@ -68,43 +90,41 @@ async def create_dynamic_endpoints(app: FastAPI, api_dependency=None):
Body(default_value, description=param_desc),
)
FormModel = create_model(f"{endpoint_name}_form_model", **model_fields)
if model_fields:
FormModel = create_model(f"{endpoint_name}_form_model", **model_fields)
def make_endpoint_func(endpoint_name: str, FormModel, session: ClientSession):
async def tool_endpoint(form_data: FormModel):
args = form_data.model_dump(exclude_none=True)
print(f"Calling {endpoint_name} with arguments:", args)
result = await session.call_tool(endpoint_name, arguments=args)
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 = content.data
image_data = f"data:{content.mimeType};base64,{image_data}"
response.append(image_data)
elif isinstance(content, types.EmbeddedResource):
# TODO: Handle embedded resources
response.append("Embedded resource not supported yet.")
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)
result = await session.call_tool(endpoint_name, arguments=args)
return process_tool_response(result)
return response
return tool
return tool_endpoint
tool_handler = make_endpoint_func(endpoint_name, FormModel, session)
else:
tool = make_endpoint_func(endpoint_name, FormModel, session)
def make_endpoint_func_no_args(
endpoint_name: str, session: ClientSession
): # Parameterless endpoint
async def tool(): # No parameters
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)
app.post(
f"/{endpoint_name}",
summary=endpoint_name.replace("_", " ").title(),
description=endpoint_description,
dependencies=[Depends(api_dependency)] if api_dependency else [],
)(tool)
)(tool_handler)
@asynccontextmanager
@ -145,7 +165,6 @@ async def run(
cors_allow_origins=["*"],
**kwargs,
):
# Server API Key
api_dependency = get_verify_api_key(api_key) if api_key else None
@ -158,10 +177,16 @@ async def run(
)
version = kwargs.get("version") or "1.0"
ssl_certfile = kwargs.get("ssl_certfile")
ssl_keyfile= kwargs.get("ssl_keyfile")
ssl_keyfile = kwargs.get("ssl_keyfile")
path_prefix = kwargs.get("path_prefix") or "/"
main_app = FastAPI(
title=name, description=description, version=version, ssl_certfile=ssl_certfile, ssl_keyfile=ssl_keyfile, lifespan=lifespan
title=name,
description=description,
version=version,
ssl_certfile=ssl_certfile,
ssl_keyfile=ssl_keyfile,
lifespan=lifespan,
)
main_app.add_middleware(
@ -183,14 +208,13 @@ async def run(
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(
title=f"{server_name}",
description=f"{server_name} MCP Server",
description=f"{server_name} MCP Server\n\n- [back to tool list](http://{host}:{port}/docs)",
version="1.0",
lifespan=lifespan,
)
@ -208,13 +232,21 @@ async def run(
sub_app.state.env = {**os.environ, **server_cfg.get("env", {})}
sub_app.state.api_dependency = api_dependency
main_app.mount(f"/{server_name}", sub_app)
main_app.mount(f"{path_prefix}{server_name}", sub_app)
main_app.description += (
f"\n - [{server_name}](http://{host}:{port}/{server_name}/docs)"
)
else:
raise ValueError("You must provide either server_command or config.")
config = uvicorn.Config(app=main_app, host=host, port=port, ssl_certfile=ssl_certfile , ssl_keyfile=ssl_keyfile ,log_level="info")
config = uvicorn.Config(
app=main_app,
host=host,
port=port,
ssl_certfile=ssl_certfile,
ssl_keyfile=ssl_keyfile,
log_level="info",
)
server = uvicorn.Server(config)
await server.serve()

View File

@ -294,13 +294,14 @@ wheels = [
[[package]]
name = "mcpo"
version = "0.0.6"
version = "0.0.9"
source = { editable = "." }
dependencies = [
{ name = "click" },
{ name = "fastapi" },
{ name = "mcp" },
{ name = "passlib", extra = ["bcrypt"] },
{ name = "pydantic" },
{ name = "pyjwt", extra = ["crypto"] },
{ name = "typer" },
{ name = "uvicorn" },
@ -312,6 +313,7 @@ requires-dist = [
{ name = "fastapi", specifier = ">=0.115.12" },
{ name = "mcp", specifier = ">=1.6.0" },
{ name = "passlib", extras = ["bcrypt"], specifier = ">=1.7.4" },
{ name = "pydantic", specifier = ">=2.11.1" },
{ name = "pyjwt", extras = ["crypto"], specifier = ">=2.10.1" },
{ name = "typer", specifier = ">=0.15.2" },
{ name = "uvicorn", specifier = ">=0.34.0" },