From b931e26ebb7c2c475bd195a21247e6e96e4d331c Mon Sep 17 00:00:00 2001 From: chizukicn Date: Fri, 4 Apr 2025 01:39:40 +0800 Subject: [PATCH 01/16] fix: resolve mcpo startup fail when mcp tool parameters are missing --- src/mcpo/main.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/mcpo/main.py b/src/mcpo/main.py index bd00599..f0c0fa5 100644 --- a/src/mcpo/main.py +++ b/src/mcpo/main.py @@ -58,7 +58,8 @@ async def create_dynamic_endpoints(app: FastAPI, api_dependency=None): # 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) From 9854e482057e530822d5e8856810e71dce5a8b70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=97=BA=20=E6=9D=9C?= Date: Fri, 4 Apr 2025 15:13:54 +0800 Subject: [PATCH 02/16] When loading multiple MCP Servers from config file, add links jumping to tool's docs from root docs, also add link to jump back. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit for example: http://127.0.0.1:8000/docs ```markdown Automatically generated API from MCP Tool Schemas available tools: - memory - time ``` http://127.0.0.1:8000/memory/docs ```markdown memory MCP Server - back to tool list ``` --- src/mcpo/main.py | 47 ++++++++++++++++++++--------------------------- 1 file changed, 20 insertions(+), 27 deletions(-) diff --git a/src/mcpo/main.py b/src/mcpo/main.py index bd00599..29ee4f2 100644 --- a/src/mcpo/main.py +++ b/src/mcpo/main.py @@ -1,18 +1,16 @@ -from fastapi import FastAPI, Body, Depends -from fastapi.middleware.cors import CORSMiddleware -from starlette.routing import Mount -from pydantic import create_model +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 typing import Dict, Any, Optional -import uvicorn -import json -import os - 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): @@ -42,9 +40,7 @@ async def create_dynamic_endpoints(app: FastAPI, api_dependency=None): server_info = getattr(result, "serverInfo", None) if server_info: app.title = server_info.name or app.title - app.description = ( - f"{server_info.name} MCP Server" if server_info.name else app.description - ) + app.description = app.description app.version = server_info.version or app.version tools_result = await session.list_tools() @@ -139,13 +135,12 @@ async def lifespan(app: FastAPI): async def run( - host: str = "127.0.0.1", - port: int = 8000, - api_key: Optional[str] = "", - cors_allow_origins=["*"], - **kwargs, + host: str = "127.0.0.1", + port: int = 8000, + api_key: Optional[str] = "", + cors_allow_origins=["*"], + **kwargs, ): - # Server API Key api_dependency = get_verify_api_key(api_key) if api_key else None @@ -154,11 +149,11 @@ async def run( 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" + 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") + ssl_keyfile = kwargs.get("ssl_keyfile") main_app = FastAPI( title=name, description=description, version=version, ssl_certfile=ssl_certfile, ssl_keyfile=ssl_keyfile, lifespan=lifespan @@ -183,14 +178,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 +202,12 @@ 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.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() From 6c003f8bef80d9c7bc421694fe1125bb987cb171 Mon Sep 17 00:00:00 2001 From: Your Full Name Date: Fri, 4 Apr 2025 17:54:19 +0200 Subject: [PATCH 03/16] As discussed (discussions/24), this new parameter 'prefix' adds an optional prefix to the routes of the services --- src/mcpo/__init__.py | 15 +++++++++++++++ src/mcpo/main.py | 3 ++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/mcpo/__init__.py b/src/mcpo/__init__.py index 432cd8a..2edf15d 100644 --- a/src/mcpo/__init__.py +++ b/src/mcpo/__init__.py @@ -47,6 +47,9 @@ def main( ssl_keyfile: Annotated[ Optional[str], typer.Option("--ssl-keyfile", "-k", help="SSL keyfile") ] = None, + prefix: Annotated[ + Optional[str], typer.Option("--prefix", "-x", help="URL prefix") + ] = None, ): server_command = None if not config: @@ -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 prefix is None: + # Set default value + prefix = "/" + # if prefix doesn't end with a /, add it + if not prefix.endswith("/"): + prefix = f"{prefix}/" + # if prefix doesn't start with a /, add it + if not prefix.startswith("/"): + prefix = f"/{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, + prefix=prefix, ) ) diff --git a/src/mcpo/main.py b/src/mcpo/main.py index bd00599..d99e2aa 100644 --- a/src/mcpo/main.py +++ b/src/mcpo/main.py @@ -159,6 +159,7 @@ async def run( version = kwargs.get("version") or "1.0" ssl_certfile = kwargs.get("ssl_certfile") ssl_keyfile= kwargs.get("ssl_keyfile") + prefix = kwargs.get("prefix") or "/" main_app = FastAPI( title=name, description=description, version=version, ssl_certfile=ssl_certfile, ssl_keyfile=ssl_keyfile, lifespan=lifespan @@ -209,7 +210,7 @@ async def run( sub_app.state.api_dependency = api_dependency - main_app.mount(f"/{server_name}", sub_app) + main_app.mount(f"/mcpo/{server_name}", sub_app) else: raise ValueError("You must provide either server_command or config.") From d74e1ceeef14d6abf8e399b8a7875b61e21d01c7 Mon Sep 17 00:00:00 2001 From: Your Full Name Date: Fri, 4 Apr 2025 18:02:44 +0200 Subject: [PATCH 04/16] added usage of the new parameter --- 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 d99e2aa..3b59f66 100644 --- a/src/mcpo/main.py +++ b/src/mcpo/main.py @@ -210,7 +210,7 @@ async def run( sub_app.state.api_dependency = api_dependency - main_app.mount(f"/mcpo/{server_name}", sub_app) + main_app.mount(f"{prefix}{server_name}", sub_app) else: raise ValueError("You must provide either server_command or config.") From 12b61f4ed720042b56ba6d53bb1d6921e2ecb4d6 Mon Sep 17 00:00:00 2001 From: Your Full Name Date: Fri, 4 Apr 2025 19:49:18 +0200 Subject: [PATCH 05/16] renamed prefix to path_prefix --- src/mcpo/__init__.py | 6 +++--- src/mcpo/main.py | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/mcpo/__init__.py b/src/mcpo/__init__.py index 2edf15d..82e7600 100644 --- a/src/mcpo/__init__.py +++ b/src/mcpo/__init__.py @@ -47,8 +47,8 @@ def main( ssl_keyfile: Annotated[ Optional[str], typer.Option("--ssl-keyfile", "-k", help="SSL keyfile") ] = None, - prefix: Annotated[ - Optional[str], typer.Option("--prefix", "-x", help="URL prefix") + path_prefix: Annotated[ + Optional[str], typer.Option("--path_prefix", "-x", help="URL prefix") ] = None, ): server_command = None @@ -109,7 +109,7 @@ def main( server_command=server_command, ssl_certfile=ssl_certfile, ssl_keyfile=ssl_keyfile, - prefix=prefix, + path_prefix=path_prefix, ) ) diff --git a/src/mcpo/main.py b/src/mcpo/main.py index 3b59f66..ef269fb 100644 --- a/src/mcpo/main.py +++ b/src/mcpo/main.py @@ -159,7 +159,7 @@ async def run( version = kwargs.get("version") or "1.0" ssl_certfile = kwargs.get("ssl_certfile") ssl_keyfile= kwargs.get("ssl_keyfile") - prefix = kwargs.get("prefix") or "/" + 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 @@ -210,7 +210,7 @@ async def run( sub_app.state.api_dependency = api_dependency - main_app.mount(f"{prefix}{server_name}", sub_app) + main_app.mount(f"{path_prefix}{server_name}", sub_app) else: raise ValueError("You must provide either server_command or config.") From ce81196fc2a4391430246604251de3a4e2d75978 Mon Sep 17 00:00:00 2001 From: Your Full Name Date: Fri, 4 Apr 2025 20:07:18 +0200 Subject: [PATCH 06/16] finished renaming of prefix --- src/mcpo/__init__.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/mcpo/__init__.py b/src/mcpo/__init__.py index 82e7600..93b47d9 100644 --- a/src/mcpo/__init__.py +++ b/src/mcpo/__init__.py @@ -85,15 +85,15 @@ def main( os.environ[key] = value # Whatever the prefix is, make sure it starts and ends with a / - if prefix is None: + if path_prefix is None: # Set default value - prefix = "/" + path_prefix = "/" # if prefix doesn't end with a /, add it - if not prefix.endswith("/"): - prefix = f"{prefix}/" + if not path_prefix.endswith("/"): + path_prefix = f"{path_prefix}/" # if prefix doesn't start with a /, add it - if not prefix.startswith("/"): - prefix = f"/{prefix}" + if not path_prefix.startswith("/"): + path_prefix = f"/{path_prefix}" # Run your async run function from mcpo.main asyncio.run( From c1c82c3df0a578ae581cf96a4a773f2ad19b0743 Mon Sep 17 00:00:00 2001 From: Your Full Name Date: Fri, 4 Apr 2025 20:08:32 +0200 Subject: [PATCH 07/16] merge final correction --- 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 4cf011b..625c57f 100644 --- a/src/mcpo/main.py +++ b/src/mcpo/main.py @@ -154,7 +154,7 @@ 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( From 81e095b5d79a445e28f0f27aabf3851f7ab97fad Mon Sep 17 00:00:00 2001 From: AlvinNorin Date: Sun, 6 Apr 2025 20:04:14 +0200 Subject: [PATCH 08/16] Handle empty input schemas and add support for no-argument tool endpoints - Check for schema properties existence before processing parameters - Create separate endpoint handlers for tools with and without input arguments - Properly handle cases where tools require no parameters Before this fix, the server would fail to start when MCP servers supplied tools that didn't take input arguments. With the fix, mcpo can now work with @jetbrains/mcp-proxy. --- src/mcpo/main.py | 78 ++++++++++++++++++++++++++++-------------------- 1 file changed, 45 insertions(+), 33 deletions(-) diff --git a/src/mcpo/main.py b/src/mcpo/main.py index 625c57f..a9812f7 100644 --- a/src/mcpo/main.py +++ b/src/mcpo/main.py @@ -8,6 +8,8 @@ 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 @@ -30,6 +32,25 @@ def get_python_type(param_type: str): return str # Fallback # 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 @@ -40,7 +61,9 @@ async def create_dynamic_endpoints(app: FastAPI, api_dependency=None): server_info = getattr(result, "serverInfo", None) if server_info: app.title = server_info.name or app.title - app.description = app.description + app.description = ( + f"{server_info.name} MCP Server" if server_info.name else app.description + ) app.version = server_info.version or app.version tools_result = await session.list_tools() @@ -51,10 +74,10 @@ 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", []) 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", "") @@ -65,43 +88,32 @@ 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) - - 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.") - - return response - - return tool_endpoint - - tool = make_endpoint_func(endpoint_name, FormModel, session) + if model_fields: + FormModel = create_model(f"{endpoint_name}_form_model", **model_fields) + + def make_endpoint_func(endpoint_name: str, FormModel, session: ClientSession): # Parameterized endpoint + async def tool_endpoint(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 tool_endpoint + + 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_endpoint(): # No parameters + result = await session.call_tool(endpoint_name, arguments={}) # Empty dict + return process_tool_response(result) # Same processor + return tool_endpoint + + 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 From 334089b4065d48a6c6998104ededf777637e54aa Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Sun, 6 Apr 2025 15:05:00 -0700 Subject: [PATCH 09/16] refac --- src/mcpo/main.py | 57 ++++++++++++++++++++++++++++++++++-------------- 1 file changed, 41 insertions(+), 16 deletions(-) diff --git a/src/mcpo/main.py b/src/mcpo/main.py index a9812f7..9d7131b 100644 --- a/src/mcpo/main.py +++ b/src/mcpo/main.py @@ -32,6 +32,7 @@ def get_python_type(param_type: str): return str # Fallback # Expand as needed. PRs welcome! + def process_tool_response(result: CallToolResult) -> list: """Universal response processor for all tool endpoints""" response = [] @@ -52,6 +53,7 @@ def process_tool_response(result: CallToolResult) -> list: 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: @@ -91,20 +93,29 @@ async def create_dynamic_endpoints(app: FastAPI, api_dependency=None): if model_fields: FormModel = create_model(f"{endpoint_name}_form_model", **model_fields) - def make_endpoint_func(endpoint_name: str, FormModel, session: ClientSession): # Parameterized endpoint - async def tool_endpoint(form_data: FormModel): + 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 tool_endpoint + + 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_endpoint(): # No parameters - result = await session.call_tool(endpoint_name, arguments={}) # Empty dict + + 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_endpoint + + return tool tool_handler = make_endpoint_func_no_args(endpoint_name, session) @@ -148,11 +159,11 @@ async def lifespan(app: FastAPI): async def run( - host: str = "127.0.0.1", - port: int = 8000, - api_key: Optional[str] = "", - cors_allow_origins=["*"], - **kwargs, + host: str = "127.0.0.1", + port: int = 8000, + api_key: Optional[str] = "", + cors_allow_origins=["*"], + **kwargs, ): # Server API Key api_dependency = get_verify_api_key(api_key) if api_key else None @@ -162,7 +173,7 @@ async def run( 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" + kwargs.get("description") or "Automatically generated API from MCP Tool Schemas" ) version = kwargs.get("version") or "1.0" ssl_certfile = kwargs.get("ssl_certfile") @@ -170,7 +181,12 @@ async def run( 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( @@ -217,11 +233,20 @@ 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}](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() From 8b41671bdde0f49cddcd3cdfff021b48ff03b79e Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Sun, 6 Apr 2025 17:52:23 -0700 Subject: [PATCH 10/16] feat: dockerfile --- .github/workflows/docker-build.yaml | 165 ++++++++++++++++++++++++++++ Dockerfile | 37 +++++++ 2 files changed, 202 insertions(+) create mode 100644 .github/workflows/docker-build.yaml create mode 100644 Dockerfile diff --git a/.github/workflows/docker-build.yaml b/.github/workflows/docker-build.yaml new file mode 100644 index 0000000..5a06d4f --- /dev/null +++ b/.github/workflows/docker-build.yaml @@ -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 }} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..72acf9b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,37 @@ +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_20.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 src/mcpo /app/mcpo +COPY pyproject.toml /app/ +WORKDIR /app + +# Install mcpo via uv +RUN uv venv \ + && uv pip install . \ + && rm -rf ~/.cache + +# 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"] \ No newline at end of file From 45c474e0f0e5cc5d81240d2619c1a74fbb7b03d1 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Sun, 6 Apr 2025 17:54:59 -0700 Subject: [PATCH 11/16] Update Dockerfile Co-Authored-By: christag <330771+christag@users.noreply.github.com> --- Dockerfile | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index 72acf9b..a2b54a8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,8 +18,7 @@ RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \ RUN node -v && npm -v # Copy your mcpo source code (assuming in src/mcpo) -COPY src/mcpo /app/mcpo -COPY pyproject.toml /app/ +COPY . /app WORKDIR /app # Install mcpo via uv From 7785b608c925b856bc69d16b21e2e8e5e7c57c43 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Sun, 6 Apr 2025 17:59:39 -0700 Subject: [PATCH 12/16] refac --- Dockerfile | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/Dockerfile b/Dockerfile index a2b54a8..feca484 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,7 +10,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ && rm -rf /var/lib/apt/lists/* # Install Node.js and npm via NodeSource -RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \ +RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \ && apt-get install -y nodejs \ && rm -rf /var/lib/apt/lists/* @@ -21,10 +21,16 @@ RUN node -v && npm -v COPY . /app WORKDIR /app -# Install mcpo via uv -RUN uv venv \ - && uv pip install . \ - && rm -rf ~/.cache +# 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 From 4c3486175cecdeb34b07477913730eae5d85085e Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Sun, 6 Apr 2025 18:06:29 -0700 Subject: [PATCH 13/16] doc: readme --- README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.md b/README.md index 925c15f..acb3604 100644 --- a/README.md +++ b/README.md @@ -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 From c250341864a71619226f92c633abfacc5c3aefc0 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Sun, 6 Apr 2025 18:20:47 -0700 Subject: [PATCH 14/16] refac: --path_prefix --- src/mcpo/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/mcpo/__init__.py b/src/mcpo/__init__.py index 93b47d9..6109673 100644 --- a/src/mcpo/__init__.py +++ b/src/mcpo/__init__.py @@ -45,10 +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", "-x", help="URL prefix") + Optional[str], typer.Option("--path-prefix", help="URL prefix") ] = None, ): server_command = None From 17e858af4df9a98741823e8af37c82aa8c1212fc Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Sun, 6 Apr 2025 18:22:31 -0700 Subject: [PATCH 15/16] doc: changelog --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a313c1d..864eb67 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 From 8330223d19ca6f8c91798bab5c616621bd8a8930 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Sun, 6 Apr 2025 18:23:35 -0700 Subject: [PATCH 16/16] chore: bump --- pyproject.toml | 3 ++- uv.lock | 4 +++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 58eac9d..b53613f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", diff --git a/uv.lock b/uv.lock index c484470..5ceb911 100644 --- a/uv.lock +++ b/uv.lock @@ -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" },