mirror of
https://github.com/open-webui/openapi-servers
synced 2025-06-26 18:17:04 +00:00
init
This commit is contained in:
commit
d79d0ce834
21
LICENSE
Normal file
21
LICENSE
Normal file
@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 Timothy Jaeryang Baek
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
68
README.md
Normal file
68
README.md
Normal file
@ -0,0 +1,68 @@
|
||||
# 🌟 OpenAPI Tool Servers
|
||||
|
||||
This repository provides reference OpenAPI Tool Server implementations making it easy and secure for developers to integrate external tooling and data sources into LLM agents and workflows. Designed for maximum ease of use and minimal learning curve, these implementations utilize the widely adopted and battle-tested [OpenAPI specification](https://www.openapis.org/) as the standard protocol.
|
||||
|
||||
By leveraging OpenAPI, we eliminate the need for a proprietary or unfamiliar communication protocol, ensuring you can quickly and confidently build or integrate servers. This means less time spent figuring out custom interfaces and more time building powerful tools that enhance your AI applications.
|
||||
|
||||
## ☝️ Why OpenAPI?
|
||||
|
||||
- **Established Standard**: OpenAPI is a widely used, production-proven API standard backed by thousands of tools, companies, and communities.
|
||||
|
||||
- **No Reinventing the Wheel**: No additional documentation or proprietary spec confusion. If you build REST APIs or use OpenAPI today, you're already set.
|
||||
|
||||
- **Easy Integration & Hosting**: Deploy your tool servers externally or locally without vendor lock-in or complex configurations.
|
||||
|
||||
- **Strong Security Focus**: Built around HTTP/REST APIs, OpenAPI inherently supports widely used, secure communication methods including HTTPS and well-proven authentication standards (OAuth, JWT, API Keys).
|
||||
|
||||
- **Future-Friendly & Stable**: Unlike less mature or experimental protocols, OpenAPI promises reliability, stability, and long-term community support.
|
||||
|
||||
## 🚀 Quickstart
|
||||
|
||||
Get started quickly with our reference FastAPI-based implementations provided in the `servers/` directory. (You can adapt these examples into your preferred stack as needed, such as using [FastAPI](https://fastapi.tiangolo.com/), [FastOpenAPI](https://github.com/mr-fatalyst/fastopenapi) or any other OpenAPI-compatible library):
|
||||
|
||||
```bash
|
||||
git clone https://github.com/open-webui/openapi-servers
|
||||
cd openapi-servers
|
||||
|
||||
# Example: Installing dependencies for a specific server 'filesystem'
|
||||
cd servers/filesystem
|
||||
pip install -r requirements.txt
|
||||
uvicorn main:app --reload
|
||||
```
|
||||
|
||||
Now, simply point your OpenAPI-compatible clients or AI agents to your local or publicly deployed URL—no configuration headaches, no complicated transports.
|
||||
|
||||
## 📂 Server Examples
|
||||
|
||||
Reference implementations provided in this repository demonstrate common use-cases clearly and simply:
|
||||
|
||||
- **Filesystem Access** _(servers/filesystem)_ - Manage local file operations safely with configurable restrictions.
|
||||
- **Git Server** _(servers/git)_ - Expose Git repositories for searching, reading, and possibly writing via controlled API endpoints.
|
||||
- **Database Server** _(servers/database)_ - Query and inspect database schemas across common DB engines like PostgreSQL, MySQL, and SQLite.
|
||||
- **Memory & Knowledge Graph** _(servers/memory)_ - Persistent memory management and semantic knowledge querying using popular and reliable storage techniques.
|
||||
- **Web Search & Fetch** _(servers/web-search)_ - Retrieve and convert web-based content securely into structured API results usable by LLMs.
|
||||
|
||||
(More examples and reference implementations will be actively developed and continually updated.)
|
||||
|
||||
## 🔌 Bridge to MCP (Optional)
|
||||
|
||||
For your convenience, we also provide a simple, secure MCP-to-OpenAPI proxy server. This enables tool providers who initially implemented MCP servers to expose them effortlessly as standard OpenAPI-compatible APIs, ensuring existing MCP servers and resources remain accessible without additional hassle.
|
||||
|
||||
**Example usage:**
|
||||
|
||||
```bash
|
||||
cd servers/mcp-proxy
|
||||
pip install -r requirements.txt
|
||||
uvicorn proxy:app --reload
|
||||
```
|
||||
|
||||
This can simplify your migration or integration path, avoiding headaches typically associated with MCP's transport and security complexities.
|
||||
|
||||
## 📜 License
|
||||
|
||||
Licensed under [MIT License](LICENSE).
|
||||
|
||||
## 🌱 Open WebUI Community
|
||||
|
||||
- For general discussions, technical exchange, and announcements, visit our [Community Discussions](https://github.com/open-webui/openapi-servers/discussions) page.
|
||||
- Have ideas or feedback? Please open an issue!
|
245
servers/filesystem/main.py
Normal file
245
servers/filesystem/main.py
Normal file
@ -0,0 +1,245 @@
|
||||
from fastapi import FastAPI, HTTPException, Body
|
||||
from fastapi.responses import PlainTextResponse
|
||||
from pydantic import BaseModel, Field
|
||||
import os
|
||||
import pathlib
|
||||
import asyncio
|
||||
from typing import List, Optional, Literal
|
||||
import difflib
|
||||
|
||||
app = FastAPI(
|
||||
title="Secure Filesystem API",
|
||||
version="0.2.0",
|
||||
description="A secure file manipulation server for reading, editing, writing, listing, and searching files with access restrictions.",
|
||||
)
|
||||
|
||||
# Constants
|
||||
ALLOWED_DIRECTORIES = [
|
||||
str(pathlib.Path(os.path.expanduser("~/mydir")).resolve())
|
||||
] # 👈 Replace with your paths
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# Utility functions
|
||||
# ------------------------------------------------------------------------------
|
||||
|
||||
|
||||
def normalize_path(requested_path: str) -> pathlib.Path:
|
||||
requested = pathlib.Path(os.path.expanduser(requested_path)).resolve()
|
||||
for allowed in ALLOWED_DIRECTORIES:
|
||||
if str(requested).startswith(allowed):
|
||||
return requested
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail=f"Access denied: {requested} is outside allowed directories.",
|
||||
)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# Pydantic Schemas
|
||||
# ------------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ReadFileRequest(BaseModel):
|
||||
path: str = Field(..., description="Path to the file to read")
|
||||
|
||||
|
||||
class WriteFileRequest(BaseModel):
|
||||
path: str = Field(
|
||||
..., description="Path to write to. Existing file will be overwritten."
|
||||
)
|
||||
content: str = Field(..., description="UTF-8 encoded text content to write.")
|
||||
|
||||
|
||||
class EditOperation(BaseModel):
|
||||
oldText: str = Field(
|
||||
..., description="Text to find and replace (exact match required)"
|
||||
)
|
||||
newText: str = Field(..., description="Replacement text")
|
||||
|
||||
|
||||
class EditFileRequest(BaseModel):
|
||||
path: str = Field(..., description="Path to the file to edit.")
|
||||
edits: List[EditOperation] = Field(..., description="List of edits to apply.")
|
||||
dryRun: bool = Field(
|
||||
False, description="If true, only return diff without modifying file."
|
||||
)
|
||||
|
||||
|
||||
class CreateDirectoryRequest(BaseModel):
|
||||
path: str = Field(
|
||||
...,
|
||||
description="Directory path to create. Intermediate dirs are created automatically.",
|
||||
)
|
||||
|
||||
|
||||
class ListDirectoryRequest(BaseModel):
|
||||
path: str = Field(..., description="Directory path to list contents for.")
|
||||
|
||||
|
||||
class DirectoryTreeRequest(BaseModel):
|
||||
path: str = Field(
|
||||
..., description="Directory path for which to return recursive tree."
|
||||
)
|
||||
|
||||
|
||||
class SearchFilesRequest(BaseModel):
|
||||
path: str = Field(..., description="Base directory to search in.")
|
||||
pattern: str = Field(
|
||||
..., description="Filename pattern (case-insensitive substring match)."
|
||||
)
|
||||
excludePatterns: Optional[List[str]] = Field(
|
||||
default=[], description="Patterns to exclude."
|
||||
)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# Routes
|
||||
# ------------------------------------------------------------------------------
|
||||
|
||||
|
||||
@app.post("/read_file", response_class=PlainTextResponse, summary="Read a file")
|
||||
async def read_file(data: ReadFileRequest = Body(...)):
|
||||
"""
|
||||
Read the entire contents of a file.
|
||||
"""
|
||||
path = normalize_path(data.path)
|
||||
try:
|
||||
return path.read_text(encoding="utf-8")
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
@app.post("/write_file", response_class=PlainTextResponse, summary="Write to a file")
|
||||
async def write_file(data: WriteFileRequest = Body(...)):
|
||||
"""
|
||||
Write content to a file, overwriting if it exists.
|
||||
"""
|
||||
path = normalize_path(data.path)
|
||||
try:
|
||||
path.write_text(data.content, encoding="utf-8")
|
||||
return f"Successfully wrote to {data.path}"
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
@app.post(
|
||||
"/edit_file", response_class=PlainTextResponse, summary="Edit a file with diff"
|
||||
)
|
||||
async def edit_file(data: EditFileRequest = Body(...)):
|
||||
"""
|
||||
Apply a list of edits to a text file. Support dry-run to get unified diff.
|
||||
"""
|
||||
path = normalize_path(data.path)
|
||||
original = path.read_text(encoding="utf-8")
|
||||
modified = original
|
||||
|
||||
for edit in data.edits:
|
||||
if edit.oldText not in modified:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"oldText not found in content: {edit.oldText[:50]}",
|
||||
)
|
||||
modified = modified.replace(edit.oldText, edit.newText, 1)
|
||||
|
||||
if data.dryRun:
|
||||
diff = difflib.unified_diff(
|
||||
original.splitlines(keepends=True),
|
||||
modified.splitlines(keepends=True),
|
||||
fromfile="original",
|
||||
tofile="modified",
|
||||
)
|
||||
return "".join(diff)
|
||||
|
||||
path.write_text(modified, encoding="utf-8")
|
||||
return f"Successfully edited file {data.path}"
|
||||
|
||||
|
||||
@app.post(
|
||||
"/create_directory", response_class=PlainTextResponse, summary="Create a directory"
|
||||
)
|
||||
async def create_directory(data: CreateDirectoryRequest = Body(...)):
|
||||
"""
|
||||
Create a new directory recursively.
|
||||
"""
|
||||
dir_path = normalize_path(data.path)
|
||||
try:
|
||||
dir_path.mkdir(parents=True, exist_ok=True)
|
||||
return f"Successfully created directory {data.path}"
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
@app.post(
|
||||
"/list_directory", response_class=PlainTextResponse, summary="List a directory"
|
||||
)
|
||||
async def list_directory(data: ListDirectoryRequest = Body(...)):
|
||||
"""
|
||||
List contents of a directory.
|
||||
"""
|
||||
dir_path = normalize_path(data.path)
|
||||
if not dir_path.is_dir():
|
||||
raise HTTPException(status_code=400, detail="Provided path is not a directory")
|
||||
|
||||
listing = []
|
||||
for entry in dir_path.iterdir():
|
||||
prefix = "[DIR]" if entry.is_dir() else "[FILE]"
|
||||
listing.append(f"{prefix} {entry.name}")
|
||||
|
||||
return "\n".join(listing)
|
||||
|
||||
|
||||
@app.post("/directory_tree", summary="Recursive directory tree")
|
||||
async def directory_tree(data: DirectoryTreeRequest = Body(...)):
|
||||
"""
|
||||
Recursively return a tree structure of a directory.
|
||||
"""
|
||||
base_path = normalize_path(data.path)
|
||||
|
||||
def build_tree(current: pathlib.Path):
|
||||
entries = []
|
||||
for item in current.iterdir():
|
||||
entry = {
|
||||
"name": item.name,
|
||||
"type": "directory" if item.is_dir() else "file",
|
||||
}
|
||||
if item.is_dir():
|
||||
entry["children"] = build_tree(item)
|
||||
entries.append(entry)
|
||||
return entries
|
||||
|
||||
return build_tree(base_path)
|
||||
|
||||
|
||||
@app.post("/search_files", summary="Search for files")
|
||||
async def search_files(data: SearchFilesRequest = Body(...)):
|
||||
"""
|
||||
Search files and directories matching a pattern.
|
||||
"""
|
||||
base_path = normalize_path(data.path)
|
||||
results = []
|
||||
|
||||
for root, dirs, files in os.walk(base_path):
|
||||
root_path = pathlib.Path(root)
|
||||
# Apply exclusion patterns
|
||||
excluded = False
|
||||
for pattern in data.excludePatterns:
|
||||
if pathlib.Path(root).match(pattern):
|
||||
excluded = True
|
||||
break
|
||||
if excluded:
|
||||
continue
|
||||
for item in files + dirs:
|
||||
if data.pattern.lower() in item.lower():
|
||||
result_path = root_path / item
|
||||
if any(str(result_path).startswith(alt) for alt in ALLOWED_DIRECTORIES):
|
||||
results.append(str(result_path))
|
||||
|
||||
return {"matches": results or ["No matches found"]}
|
||||
|
||||
|
||||
@app.get("/list_allowed_directories", summary="List access-permitted directories")
|
||||
async def list_allowed_directories():
|
||||
"""
|
||||
Show all directories this server can access.
|
||||
"""
|
||||
return {"allowed_directories": ALLOWED_DIRECTORIES}
|
0
servers/mcp-proxy/main.py
Normal file
0
servers/mcp-proxy/main.py
Normal file
161
servers/time/main.py
Normal file
161
servers/time/main.py
Normal file
@ -0,0 +1,161 @@
|
||||
from fastapi import FastAPI, HTTPException, Body
|
||||
from pydantic import BaseModel, Field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Literal
|
||||
import pytz
|
||||
from dateutil import parser as dateutil_parser
|
||||
|
||||
app = FastAPI(
|
||||
title="Secure Time Utilities API",
|
||||
version="1.0.0",
|
||||
description="Provides secure UTC/local time retrieval, formatting, timezone conversion, and comparison.",
|
||||
)
|
||||
|
||||
# -------------------------------
|
||||
# Pydantic models
|
||||
# -------------------------------
|
||||
|
||||
|
||||
class FormatTimeInput(BaseModel):
|
||||
format: str = Field(
|
||||
"%Y-%m-%d %H:%M:%S", description="Python strftime format string"
|
||||
)
|
||||
timezone: str = Field(
|
||||
"UTC", description="IANA timezone name (e.g., UTC, America/New_York)"
|
||||
)
|
||||
|
||||
|
||||
class ConvertTimeInput(BaseModel):
|
||||
timestamp: str = Field(
|
||||
..., description="ISO 8601 formatted time string (e.g., 2024-01-01T12:00:00Z)"
|
||||
)
|
||||
from_tz: str = Field(
|
||||
..., description="Original IANA time zone of input (e.g. UTC or Europe/Berlin)"
|
||||
)
|
||||
to_tz: str = Field(..., description="Target IANA time zone to convert to")
|
||||
|
||||
|
||||
class ElapsedTimeInput(BaseModel):
|
||||
start: str = Field(..., description="Start timestamp in ISO 8601 format")
|
||||
end: str = Field(..., description="End timestamp in ISO 8601 format")
|
||||
units: Literal["seconds", "minutes", "hours", "days"] = Field(
|
||||
"seconds", description="Unit for elapsed time"
|
||||
)
|
||||
|
||||
|
||||
class ParseTimestampInput(BaseModel):
|
||||
timestamp: str = Field(
|
||||
..., description="Flexible input timestamp string (e.g., 2024-06-01 12:00 PM)"
|
||||
)
|
||||
timezone: str = Field(
|
||||
"UTC", description="Assumed timezone if none is specified in input"
|
||||
)
|
||||
|
||||
|
||||
# -------------------------------
|
||||
# Routes
|
||||
# -------------------------------
|
||||
|
||||
|
||||
@app.get("/get_current_utc_time", summary="Current UTC time")
|
||||
def get_current_utc():
|
||||
"""
|
||||
Returns the current time in UTC in ISO format.
|
||||
"""
|
||||
return {"utc": datetime.utcnow().replace(tzinfo=timezone.utc).isoformat()}
|
||||
|
||||
|
||||
@app.get("/get_current_local_time", summary="Current Local Time")
|
||||
def get_current_local():
|
||||
"""
|
||||
Returns the current time in local timezone in ISO format.
|
||||
"""
|
||||
return {"local_time": datetime.now().isoformat()}
|
||||
|
||||
|
||||
@app.post("/format_time", summary="Format current time")
|
||||
def format_current_time(data: FormatTimeInput):
|
||||
"""
|
||||
Return the current time formatted for a specific timezone and format.
|
||||
"""
|
||||
try:
|
||||
tz = pytz.timezone(data.timezone)
|
||||
except Exception:
|
||||
raise HTTPException(
|
||||
status_code=400, detail=f"Invalid timezone: {data.timezone}"
|
||||
)
|
||||
now = datetime.now(tz)
|
||||
try:
|
||||
return {"formatted_time": now.strftime(data.format)}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid format string: {e}")
|
||||
|
||||
|
||||
@app.post("/convert_time", summary="Convert between timezones")
|
||||
def convert_time(data: ConvertTimeInput):
|
||||
"""
|
||||
Convert a timestamp from one timezone to another.
|
||||
"""
|
||||
try:
|
||||
from_zone = pytz.timezone(data.from_tz)
|
||||
to_zone = pytz.timezone(data.to_tz)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid timezone: {e}")
|
||||
|
||||
try:
|
||||
dt = dateutil_parser.parse(data.timestamp)
|
||||
if dt.tzinfo is None:
|
||||
dt = from_zone.localize(dt)
|
||||
else:
|
||||
dt = dt.astimezone(from_zone)
|
||||
converted = dt.astimezone(to_zone)
|
||||
return {"converted_time": converted.isoformat()}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid timestamp: {e}")
|
||||
|
||||
|
||||
@app.post("/elapsed_time", summary="Time elapsed between timestamps")
|
||||
def elapsed_time(data: ElapsedTimeInput):
|
||||
"""
|
||||
Calculate the difference between two timestamps in chosen units.
|
||||
"""
|
||||
try:
|
||||
start_dt = dateutil_parser.parse(data.start)
|
||||
end_dt = dateutil_parser.parse(data.end)
|
||||
delta = end_dt - start_dt
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid timestamps: {e}")
|
||||
|
||||
seconds = delta.total_seconds()
|
||||
result = {
|
||||
"seconds": seconds,
|
||||
"minutes": seconds / 60,
|
||||
"hours": seconds / 3600,
|
||||
"days": seconds / 86400,
|
||||
}
|
||||
|
||||
return {"elapsed": result[data.units], "unit": data.units}
|
||||
|
||||
|
||||
@app.post("/parse_timestamp", summary="Parse and normalize timestamps")
|
||||
def parse_timestamp(data: ParseTimestampInput):
|
||||
"""
|
||||
Parse human-friendly input timestamp and return standardized UTC ISO time.
|
||||
"""
|
||||
try:
|
||||
tz = pytz.timezone(data.timezone)
|
||||
dt = dateutil_parser.parse(data.timestamp)
|
||||
if dt.tzinfo is None:
|
||||
dt = tz.localize(dt)
|
||||
dt_utc = dt.astimezone(pytz.utc)
|
||||
return {"utc": dt_utc.isoformat()}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f"Could not parse: {e}")
|
||||
|
||||
|
||||
@app.get("/list_time_zones", summary="All valid time zones")
|
||||
def list_time_zones():
|
||||
"""
|
||||
Return a list of all valid IANA time zones.
|
||||
"""
|
||||
return pytz.all_timezones
|
Loading…
Reference in New Issue
Block a user