Skip to content

Commit bbb63a7

Browse files
committed
perf: add mcp server
1 parent 57d1d20 commit bbb63a7

24 files changed

Lines changed: 13915 additions & 2129 deletions

.vscode/launch.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
"module": "uvicorn",
1818
"python": "${workspaceFolder}/.venv/bin/python",
1919
"args": [
20-
"open_webui.main:app",
20+
"main:app",
2121
"--port",
2222
"8083",
2323
"--host",

backend/main.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import uvicorn
2+
from contextlib import asynccontextmanager
3+
from fastapi import FastAPI
4+
from fastapi.middleware.cors import CORSMiddleware
5+
from dotenv import find_dotenv, load_dotenv
6+
from pathlib import Path
7+
8+
from open_webui.main import app as open_webui_app
9+
from mcp_server.server import mcp
10+
11+
BASE_DIR = Path(__file__).parent.parent
12+
print(f"BASE_DIR: {BASE_DIR}")
13+
load_dotenv(find_dotenv(str(BASE_DIR / ".env")))
14+
15+
mcp_app = mcp.http_app(transport="streamable-http", path="/")
16+
17+
18+
@asynccontextmanager
19+
async def merged_lifespan(app: FastAPI):
20+
# Run open_webui_app's lifespan first to initialize its state
21+
# Use the lifespan_context method to properly run it
22+
if hasattr(open_webui_app, 'router') and hasattr(open_webui_app.router, 'lifespan_context'):
23+
async with open_webui_app.router.lifespan_context(open_webui_app):
24+
# Now run mcp_app's lifespan
25+
# FastMCP requires lifespan to be passed to parent app
26+
# Check if mcp_app has a lifespan attribute
27+
if hasattr(mcp_app, 'lifespan'):
28+
# mcp_app.lifespan is a context manager that takes the app as argument
29+
async with mcp_app.lifespan(app):
30+
yield
31+
else:
32+
yield
33+
else:
34+
# Fallback: try to get lifespan directly from open_webui_app
35+
# This shouldn't happen with FastAPI, but just in case
36+
if hasattr(mcp_app, 'lifespan'):
37+
async with mcp_app.lifespan(app):
38+
yield
39+
else:
40+
yield
41+
42+
43+
# Create main app with merged lifespan
44+
# FastMCP requires lifespan to be passed to parent app
45+
app = FastAPI(lifespan=merged_lifespan)
46+
47+
# Mount sub-applications
48+
# This preserves each app's state, so request.app.state.config works correctly
49+
# When a request comes to open_webui_app, request.app will point to open_webui_app
50+
app.mount("/mcp", mcp_app)
51+
app.mount("/", open_webui_app)
52+
53+
# Add CORS middleware
54+
app.add_middleware(
55+
CORSMiddleware,
56+
allow_origins=["*"],
57+
allow_methods=["*"],
58+
allow_headers=["*"],
59+
allow_credentials=True,
60+
)
61+
62+
63+
if __name__ == "__main__":
64+
uvicorn.run(app, host="0.0.0.0", port=8083)

backend/mcp_server/README.md

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
## JumpServer FastMCP Server
2+
3+
This project exposes a JumpServer-compatible MCP server using the generic `/api/v1/resources/` endpoints. It relies on [fastmcp](https://github.com/modelcontextprotocol/fastmcp) and `httpx`.
4+
5+
### Configuration
6+
7+
Set the following environment variables (or create a `.env` file):
8+
9+
- `JUMPSERVER_HOST` – Base URL of your API server (default: `http://localhost:8080`)
10+
- `JUMPSERVER_TOKEN` – JumpServer API token (optional but recommended)
11+
12+
### Installation
13+
14+
```bash
15+
# Create virtual environment
16+
python -m venv .venv
17+
source .venv/bin/activate # On Windows: .venv\Scripts\activate
18+
19+
# Install dependencies
20+
pip install -r requirements.txt
21+
```
22+
23+
### Running
24+
25+
```bash
26+
python server.py # exposes MCP over HTTP on port 8000
27+
```
28+
29+
### Available MCP Interfaces
30+
31+
#### Resources (Data URIs)
32+
33+
- `data://resources` – Discover supported resource names (with fallback list)
34+
- `data://resources/names/` – Get static list of supported resource names
35+
- `data://resources/{resource}/schema/{action}` – Inspect field definitions for `GET`, `POST`, `PATCH`, or `PUT` via the JumpServer `OPTIONS` metadata
36+
37+
#### Tools
38+
39+
- `tools://get-supported-resources` – Get the list of supported resources from JumpServer
40+
- `tools://list-resource` – List entries within a resource, supports `limit`, `offset`, `search`
41+
- `tools://get-resource` – Get a single resource item by ID
42+
- `tools://create-resource` – Create new resource entries with arbitrary payloads
43+
- `tools://update-resource` – Update existing entries (identifier fields must be provided in the payload)
44+
- `tools://list-users` – List JumpServer users via `/api/v1/users/users/` with login/active filters
45+
- `tools://update-user-status` – Toggle `is_login_blocked` / `is_active` flags for a user
46+
47+
### API Endpoints
48+
49+
The server aggregates JumpServer's REST API into a unified interface:
50+
51+
- `GET /api/v1/resources/` – Returns supported resources
52+
- `GET /api/v1/resources/{resource}/` – Get list with `search`, `offset`, `limit` pagination
53+
- `GET /api/v1/resources/{resource}/{id}/` – Get resource details
54+
- `OPTIONS /api/v1/resources/{resource}/?action=POST|PUT|PATCH` – Get schema for create/update operations
55+
- `POST /api/v1/resources/{resource}/` – Create resource
56+
- `PATCH /api/v1/resources/{resource}/` – Update resource
57+
- `GET /api/v1/users/users/` – List users with `is_login_blocked` / `is_active` filters
58+
- `PATCH /api/v1/users/users/{id}/` – Update user status flags (login lock / active state)
59+
60+
### Usage Tips
61+
62+
1. First, use `data://resources` to discover available resource types
63+
2. Use `data://resources/{resource}/schema/{action}` to understand required fields before creating or updating
64+
3. Leverage the schema resource to understand which fields are required before invoking the creation or update tools
65+
66+

backend/mcp_server/__init__.py

Whitespace-only changes.

backend/mcp_server/app.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
"""
2+
JumpServer MCP Server Application
3+
4+
This module initializes the FastMCP server instance.
5+
"""
6+
7+
from __future__ import annotations
8+
9+
from fastmcp import FastMCP
10+
from .auth import JumpServerAuthProvider
11+
12+
13+
mcp = FastMCP(
14+
name="JumpServer MCP Server",
15+
instructions="""
16+
Interact with JumpServer's generic resource API.
17+
Use `data://resources` to discover resource names, inspect schemas
18+
via `data://resources/{resource}/schema/{action}`, then run the tools
19+
to list, create, update, or get resource entries.
20+
""",
21+
auth=JumpServerAuthProvider(),
22+
)

backend/mcp_server/auth.py

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
"""JumpServer authentication provider for FastMCP.
2+
3+
Simple bearer token authentication using JumpServer session IDs.
4+
"""
5+
6+
from __future__ import annotations
7+
8+
import os
9+
10+
from fastmcp.server.dependencies import get_http_headers
11+
import httpx
12+
import dotenv
13+
from pydantic import AnyHttpUrl
14+
15+
from fastmcp.server.auth import TokenVerifier
16+
from fastmcp.server.auth.auth import AccessToken
17+
from fastmcp.utilities.logging import get_logger
18+
from starlette.responses import JSONResponse
19+
from starlette.routing import Route
20+
from starlette.requests import Request
21+
22+
logger = get_logger(__name__)
23+
dotenv.load_dotenv()
24+
25+
26+
class JumpServerAuthProvider(TokenVerifier):
27+
"""Simple JumpServer session token authentication.
28+
29+
Validates bearer tokens in format `jms-<sessionid>` by calling
30+
JumpServer's profile API with the session cookie.
31+
"""
32+
33+
def __init__(
34+
self,
35+
*,
36+
jumpserver_host: str | None = None,
37+
timeout_seconds: int = 10,
38+
base_url: AnyHttpUrl | str | None = None,
39+
):
40+
"""Initialize JumpServer authentication provider.
41+
42+
Args:
43+
jumpserver_host: JumpServer host URL (defaults to JUMPSERVER_HOST env var)
44+
timeout_seconds: HTTP request timeout (default: 10)
45+
base_url: Base URL of this server (optional)
46+
"""
47+
super().__init__(base_url=base_url)
48+
49+
jumpserver_host_final = jumpserver_host or os.getenv("CORE_HOST") or "http://core:8080"
50+
logger.info(f"CORE_HOST: {jumpserver_host_final}")
51+
if jumpserver_host_final:
52+
jumpserver_host_final = jumpserver_host_final.rstrip("/")
53+
54+
if not jumpserver_host_final:
55+
raise ValueError(
56+
"jumpserver_host is required - set via parameter or JUMPSERVER_HOST env var"
57+
)
58+
59+
self.jumpserver_host = jumpserver_host_final
60+
self.timeout_seconds = timeout_seconds
61+
62+
logger.info(f"Initialized JumpServer auth provider for {jumpserver_host_final}")
63+
64+
async def verify_token(self, token: str) -> AccessToken | None:
65+
"""Verify JumpServer session token."""
66+
67+
headers = get_http_headers()
68+
headers['Accept'] = 'application/json'
69+
70+
if token and token.startswith('jms'):
71+
headers.pop('authorization', '')
72+
73+
try:
74+
# Request user profile with session cookie
75+
async with httpx.AsyncClient(timeout=self.timeout_seconds) as client:
76+
response = await client.get(
77+
f"{self.jumpserver_host}/api/v1/users/profile/",
78+
headers=headers,
79+
)
80+
if response.status_code != 200:
81+
logger.debug(f"Profile API failed: {response.status_code}")
82+
return None
83+
84+
user_data = response.json()
85+
logger.info(f"Authenticated user: {user_data.get('username', 'unknown')}")
86+
87+
return AccessToken(
88+
token=token,
89+
client_id="jumpserver",
90+
scopes=[],
91+
expires_at=None,
92+
claims={
93+
"sub": str(user_data.get("id", "unknown")),
94+
"username": user_data.get("username"),
95+
"name": user_data.get("name"),
96+
"email": user_data.get("email"),
97+
"is_active": user_data.get("is_active"),
98+
"is_org_admin": user_data.get("is_org_admin", False),
99+
"is_superuser": user_data.get("is_superuser", False),
100+
"jumpserver_user_data": user_data, # Contains full user data including roles
101+
},
102+
)
103+
104+
except Exception as e:
105+
logger.debug(f"Token verification error: {e}")
106+
return None
107+
108+
def get_routes(self, mcp_path: str | None = None, **kwargs) -> list[Route]:
109+
"""Handle /register requests (MCP clients may try to register)."""
110+
async def handle_register(request: Request):
111+
return JSONResponse(
112+
status_code=400,
113+
content={
114+
"error": "client_registration_not_supported",
115+
"error_description": (
116+
"This server uses simple bearer token authentication. "
117+
"No client registration needed. Use: Authorization: Bearer jms-<sessionid>"
118+
),
119+
},
120+
)
121+
122+
return [Route("/mcp/register", handle_register, methods=["POST"])]

backend/mcp_server/mcp_server.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import uvicorn
2+
from contextlib import asynccontextmanager
3+
from fastapi import FastAPI
4+
from fastapi.middleware.cors import CORSMiddleware
5+
from fastapi.responses import JSONResponse, RedirectResponse
6+
from fastmcp import FastMCP
7+
8+
9+
fastapi_app = FastAPI(
10+
title="api",
11+
description="api + mcp",
12+
)
13+
14+
@fastapi_app.get("/")
15+
async def root():
16+
return RedirectResponse(url="/docs")
17+
18+
mcp = FastMCP("JumpServer MCP Server")
19+
20+
@mcp.tool("list-users")
21+
async def list_users():
22+
return {"users": ["user1", "user2", "user3"]}
23+
24+
# Create mcp_app first
25+
mcp_app = mcp.http_app(transport="streamable-http", path="/a")
26+
27+
28+
@asynccontextmanager
29+
async def merged_lifespan(app: FastAPI):
30+
# Run fastapi_app's lifespan if it has one
31+
if hasattr(fastapi_app, 'router') and hasattr(fastapi_app.router, 'lifespan_context'):
32+
async with fastapi_app.router.lifespan_context(fastapi_app):
33+
# Run mcp_app's lifespan
34+
# FastMCP requires lifespan to be passed to parent app
35+
if hasattr(mcp_app, 'lifespan'):
36+
async with mcp_app.lifespan(app):
37+
yield
38+
else:
39+
yield
40+
else:
41+
# If fastapi_app doesn't have lifespan, just run mcp_app's lifespan
42+
if hasattr(mcp_app, 'lifespan'):
43+
async with mcp_app.lifespan(app):
44+
yield
45+
else:
46+
yield
47+
48+
49+
# Create main app with merged lifespan
50+
# FastMCP requires lifespan to be passed to parent app
51+
app = FastAPI(lifespan=merged_lifespan)
52+
53+
# Mount sub-applications
54+
app.mount("/mcp", mcp_app)
55+
app.mount("/", fastapi_app)
56+
57+
# Add CORS middleware
58+
app.add_middleware(
59+
CORSMiddleware,
60+
allow_origins=["*"],
61+
allow_methods=["*"],
62+
allow_headers=["*"],
63+
allow_credentials=True,
64+
)
65+
66+
67+
68+
if __name__ == "__main__":
69+
uvicorn.run(
70+
app,
71+
host="0.0.0.0",
72+
port="8000",
73+
)

backend/mcp_server/mcp_server2.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
from fastmcp import FastMCP
2+
from fastapi import FastAPI
3+
import uvicorn
4+
5+
# Create MCP server
6+
mcp = FastMCP("Analytics Tools")
7+
8+
@mcp.tool
9+
def analyze_pricing(category: str) -> dict:
10+
return {
11+
"category": category,
12+
}
13+
14+
15+
# Create ASGI app from MCP server
16+
mcp_app = mcp.http_app(transport="streamable-http", path="/")
17+
18+
# Key: Pass lifespan to FastAPI
19+
app = FastAPI(title="E-commerce API", lifespan=mcp_app.lifespan)
20+
21+
# Mount the MCP server
22+
app.mount("/mcp", mcp_app)
23+
24+
# Now: API at /products/*, MCP at /analytics/mcp/
25+
26+
if __name__ == "__main__":
27+
uvicorn.run(app, host="0.0.0.0", port=8000)

0 commit comments

Comments
 (0)