├── .github ├── actions │ └── uv_setup │ │ └── action.yml └── workflows │ ├── _lint.yml │ ├── _test.yml │ ├── ci.yml │ └── release.yml ├── .gitignore ├── LICENSE ├── README.md ├── examples ├── Makefile ├── README.md ├── auth │ ├── async_client.py │ └── server.py ├── mcp │ ├── client.py │ └── server.py ├── pyproject.toml ├── react-agent │ ├── agent.py │ └── server.py ├── simple │ ├── async_client.py │ └── server.py └── uv.lock └── libs ├── o2mcp ├── Makefile ├── README.md ├── __init__.py ├── o2mcp │ └── __init__.py ├── pyproject.toml └── uv.lock ├── sdk-py ├── Makefile ├── README.md ├── pyproject.toml ├── tests │ ├── __init__.py │ └── unit_tests │ │ ├── __init__.py │ │ └── test_sdk.py ├── universal_tool_client │ └── __init__.py └── uv.lock └── server ├── .ipynb_checkpoints └── Untitled-checkpoint.ipynb ├── Makefile ├── README.md ├── __init__.py ├── pyproject.toml ├── run_integration.sh ├── tests ├── __init__.py ├── integration │ ├── __init__.py │ ├── mcp_server.py │ └── test_client.py └── unit_tests │ ├── __init__.py │ ├── test_tools.py │ ├── test_with_sdk.py │ ├── test_with_sync_sdk.py │ ├── test_without_sdk.py │ └── utils.py ├── universal_tool_server ├── __init__.py ├── _version.py ├── auth │ ├── __init__.py │ ├── exceptions.py │ ├── middleware.py │ └── types.py ├── mcp.py ├── root.py ├── splash.py └── tools.py └── uv.lock /.github/actions/uv_setup/action.yml: -------------------------------------------------------------------------------- 1 | # TODO: https://docs.astral.sh/uv/guides/integration/github/#caching 2 | 3 | name: uv-install 4 | description: Set up Python and uv 5 | 6 | inputs: 7 | python-version: 8 | description: Python version, supporting MAJOR.MINOR only 9 | required: true 10 | 11 | env: 12 | UV_VERSION: "0.5.25" 13 | 14 | runs: 15 | using: composite 16 | steps: 17 | - name: Install uv and set the python version 18 | uses: astral-sh/setup-uv@v5 19 | with: 20 | version: ${{ env.UV_VERSION }} 21 | python-version: ${{ inputs.python-version }} 22 | -------------------------------------------------------------------------------- /.github/workflows/_lint.yml: -------------------------------------------------------------------------------- 1 | name: lint 2 | 3 | on: 4 | workflow_call: 5 | inputs: 6 | working-directory: 7 | required: true 8 | type: string 9 | description: "From which folder this pipeline executes" 10 | python-version: 11 | required: true 12 | type: string 13 | description: "Python version to use" 14 | 15 | env: 16 | WORKDIR: ${{ inputs.working-directory == '' && '.' || inputs.working-directory }} 17 | 18 | # This env var allows us to get inline annotations when ruff has complaints. 19 | RUFF_OUTPUT_FORMAT: github 20 | 21 | UV_FROZEN: "true" 22 | 23 | jobs: 24 | build: 25 | name: "make lint #${{ inputs.python-version }}" 26 | runs-on: ubuntu-latest 27 | timeout-minutes: 20 28 | steps: 29 | - uses: actions/checkout@v4 30 | 31 | - name: Set up Python ${{ inputs.python-version }} + uv 32 | uses: "./.github/actions/uv_setup" 33 | with: 34 | python-version: ${{ inputs.python-version }} 35 | 36 | - name: Install dependencies 37 | working-directory: ${{ inputs.working-directory }} 38 | run: | 39 | uv sync --group test 40 | 41 | - name: Analysing the code with our lint 42 | working-directory: ${{ inputs.working-directory }} 43 | run: | 44 | make lint 45 | -------------------------------------------------------------------------------- /.github/workflows/_test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | 3 | on: 4 | workflow_call: 5 | inputs: 6 | working-directory: 7 | required: true 8 | type: string 9 | description: "From which folder this pipeline executes" 10 | python-version: 11 | required: true 12 | type: string 13 | description: "Python version to use" 14 | 15 | env: 16 | UV_FROZEN: "true" 17 | UV_NO_SYNC: "true" 18 | 19 | jobs: 20 | build: 21 | defaults: 22 | run: 23 | working-directory: ${{ inputs.working-directory }} 24 | runs-on: ubuntu-latest 25 | timeout-minutes: 20 26 | name: "make test #${{ inputs.python-version }}" 27 | steps: 28 | - uses: actions/checkout@v4 29 | 30 | - name: Set up Python ${{ inputs.python-version }} + uv 31 | uses: "./.github/actions/uv_setup" 32 | id: setup-python 33 | with: 34 | python-version: ${{ inputs.python-version }} 35 | - name: Install dependencies 36 | shell: bash 37 | run: uv sync --group test 38 | 39 | - name: Run core tests 40 | shell: bash 41 | run: | 42 | make test 43 | 44 | - name: Run Integration tests 45 | # Only run this is the working-directory is server 46 | if: ${{ inputs.working-directory == './libs/server' }} 47 | shell: bash 48 | run: | 49 | make test_integration 50 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Run CI Tests 3 | 4 | on: 5 | push: 6 | branches: [ main ] 7 | pull_request: 8 | workflow_dispatch: # Allows to trigger the workflow manually in GitHub UI 9 | 10 | # If another push to the same PR or branch happens while this workflow is still running, 11 | # cancel the earlier run in favor of the next run. 12 | # 13 | # There's no point in testing an outdated version of the code. GitHub only allows 14 | # a limited number of job runners to be active at the same time, so it's better to cancel 15 | # pointless jobs early so that more useful jobs can run sooner. 16 | concurrency: 17 | group: ${{ github.workflow }}-${{ github.ref }} 18 | cancel-in-progress: true 19 | 20 | jobs: 21 | lint: 22 | strategy: 23 | matrix: 24 | # Only lint on the min and max supported Python versions. 25 | # It's extremely unlikely that there's a lint issue on any version in between 26 | # that doesn't show up on the min or max versions. 27 | # 28 | # GitHub rate-limits how many jobs can be running at any one time. 29 | # Starting new jobs is also relatively slow, 30 | # so linting on fewer versions makes CI faster. 31 | python-version: 32 | - "3.12" 33 | uses: 34 | ./.github/workflows/_lint.yml 35 | with: 36 | working-directory: "./libs/server" 37 | python-version: ${{ matrix.python-version }} 38 | secrets: inherit 39 | test: 40 | strategy: 41 | matrix: 42 | # Only lint on the min and max supported Python versions. 43 | # It's extremely unlikely that there's a lint issue on any version in between 44 | # that doesn't show up on the min or max versions. 45 | # 46 | # GitHub rate-limits how many jobs can be running at any one time. 47 | # Starting new jobs is also relatively slow, 48 | # so linting on fewer versions makes CI faster. 49 | python-version: 50 | - "3.10" 51 | - "3.12" 52 | uses: 53 | ./.github/workflows/_test.yml 54 | with: 55 | working-directory: "./libs/server" 56 | python-version: ${{ matrix.python-version }} 57 | secrets: inherit 58 | 59 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | run-name: Release ${{ inputs.working-directory }} by @${{ github.actor }} 3 | on: 4 | workflow_call: 5 | inputs: 6 | working-directory: 7 | required: true 8 | type: string 9 | description: "From which folder this pipeline executes" 10 | workflow_dispatch: 11 | inputs: 12 | working-directory: 13 | description: "From which folder this pipeline executes" 14 | default: "libs/server" 15 | required: true 16 | type: choice 17 | options: 18 | - "libs/server" 19 | - "libs/sdk-py" 20 | - "libs/o2mcp" 21 | dangerous-nonmain-release: 22 | required: false 23 | type: boolean 24 | default: false 25 | description: "Release from a non-main branch (danger!)" 26 | 27 | env: 28 | PYTHON_VERSION: "3.11" 29 | UV_FROZEN: "true" 30 | UV_NO_SYNC: "true" 31 | 32 | jobs: 33 | build: 34 | if: github.ref == 'refs/heads/main' || inputs.dangerous-nonmain-release 35 | environment: Scheduled testing 36 | runs-on: ubuntu-latest 37 | 38 | outputs: 39 | pkg-name: ${{ steps.check-version.outputs.pkg-name }} 40 | version: ${{ steps.check-version.outputs.version }} 41 | 42 | steps: 43 | - uses: actions/checkout@v4 44 | 45 | - name: Set up Python + uv 46 | uses: "./.github/actions/uv_setup" 47 | with: 48 | python-version: ${{ env.PYTHON_VERSION }} 49 | 50 | # We want to keep this build stage *separate* from the release stage, 51 | # so that there's no sharing of permissions between them. 52 | # The release stage has trusted publishing and GitHub repo contents write access, 53 | # and we want to keep the scope of that access limited just to the release job. 54 | # Otherwise, a malicious `build` step (e.g. via a compromised dependency) 55 | # could get access to our GitHub or PyPI credentials. 56 | # 57 | # Per the trusted publishing GitHub Action: 58 | # > It is strongly advised to separate jobs for building [...] 59 | # > from the publish job. 60 | # https://github.com/pypa/gh-action-pypi-publish#non-goals 61 | - name: Build project for distribution 62 | run: uv build 63 | working-directory: ${{ inputs.working-directory }} 64 | - name: Upload build 65 | uses: actions/upload-artifact@v4 66 | with: 67 | name: dist 68 | path: ${{ inputs.working-directory }}/dist/ 69 | 70 | - name: Check Version 71 | id: check-version 72 | shell: python 73 | working-directory: ${{ inputs.working-directory }} 74 | run: | 75 | import os 76 | import tomllib 77 | with open("pyproject.toml", "rb") as f: 78 | data = tomllib.load(f) 79 | pkg_name = data["project"]["name"] 80 | version = data["project"]["version"] 81 | with open(os.environ["GITHUB_OUTPUT"], "a") as f: 82 | f.write(f"pkg-name={pkg_name}\n") 83 | f.write(f"version={version}\n") 84 | 85 | publish: 86 | needs: 87 | - build 88 | runs-on: ubuntu-latest 89 | permissions: 90 | # This permission is used for trusted publishing: 91 | # https://blog.pypi.org/posts/2023-04-20-introducing-trusted-publishers/ 92 | # 93 | # Trusted publishing has to also be configured on PyPI for each package: 94 | # https://docs.pypi.org/trusted-publishers/adding-a-publisher/ 95 | id-token: write 96 | 97 | defaults: 98 | run: 99 | working-directory: ${{ inputs.working-directory }} 100 | 101 | steps: 102 | - uses: actions/checkout@v4 103 | 104 | - name: Set up Python + uv 105 | uses: "./.github/actions/uv_setup" 106 | with: 107 | python-version: ${{ env.PYTHON_VERSION }} 108 | 109 | - uses: actions/download-artifact@v4 110 | with: 111 | name: dist 112 | path: ${{ inputs.working-directory }}/dist/ 113 | 114 | - name: Publish package distributions to PyPI 115 | uses: pypa/gh-action-pypi-publish@release/v1 116 | with: 117 | packages-dir: ${{ inputs.working-directory }}/dist/ 118 | verbose: true 119 | print-hash: true 120 | # Temp workaround since attestations are on by default as of gh-action-pypi-publish v1.11.0 121 | attestations: false 122 | 123 | mark-release: 124 | needs: 125 | - build 126 | - publish 127 | runs-on: ubuntu-latest 128 | permissions: 129 | # This permission is needed by `ncipollo/release-action` to 130 | # create the GitHub release. 131 | contents: write 132 | 133 | defaults: 134 | run: 135 | working-directory: ${{ inputs.working-directory }} 136 | 137 | steps: 138 | - uses: actions/checkout@v4 139 | 140 | - name: Set up Python + uv 141 | uses: "./.github/actions/uv_setup" 142 | with: 143 | python-version: ${{ env.PYTHON_VERSION }} 144 | 145 | - uses: actions/download-artifact@v4 146 | with: 147 | name: dist 148 | path: ${{ inputs.working-directory }}/dist/ 149 | 150 | - name: Create Tag 151 | uses: ncipollo/release-action@v1 152 | with: 153 | artifacts: "dist/*" 154 | token: ${{ secrets.GITHUB_TOKEN }} 155 | generateReleaseNotes: true 156 | tag: ${{needs.build.outputs.pkg-name}}==${{ needs.build.outputs.version }} 157 | body: ${{ needs.release-notes.outputs.release-body }} 158 | commit: main 159 | makeLatest: true -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Python-generated files 2 | __pycache__/ 3 | *.py[oc] 4 | build/ 5 | dist/ 6 | wheels/ 7 | *.egg-info 8 | 9 | # Virtual environments 10 | .venv 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 LangChain 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | > We recommend that users use FastMCP and langchain-mcp-adapters to expose langchain tools via MCP using Streamable HTTP as the transport. 2 | > universal-tool-server was created before Streamable HTTP transport was introduced into the MCP specification as a stopgap solution. 3 | 4 | ```python 5 | from langchain_core.tools import tool 6 | from langchain_mcp_adapters.tools import to_fastmcp 7 | from mcp.server.fastmcp import FastMCP 8 | 9 | 10 | @tool 11 | def add(a: int, b: int) -> int: 12 | """Add two numbers""" 13 | return a + b 14 | 15 | 16 | fastmcp_tool = to_fastmcp(add) 17 | 18 | mcp = FastMCP("Math", tools=[fastmcp_tool]) 19 | ``` 20 | 21 | # Universal Tool Server 22 | 23 | A dedicated tool server decouples the creation of specialized tools (e.g., for retrieving data from specific knowledge sources) from agent development. This separation enables different teams to contribute and manage tools independently. Agents can then be rapidly configured—by simply specifying a prompt and a set of accessible tools. This streamlined approach simplifies authentication and authorization and accelerates the deployment of agents into production. 24 | 25 | Users working in a local environment that need MCP, [can enable MCP support](#MCP-SSE). In comparison to [MCP](https://modelcontextprotocol.io/introduction), this specification uses stateless connection which makes it suitable for web deployment. 26 | 27 | ## Why 28 | 29 | - 🌐 **Stateless Web Deployment**: Deploy as a web server without the need for persistent connections, allowing easy autoscaling and load balancing. 30 | - 📡 **Simple REST Protocol**: Leverage a straightforward REST API. 31 | - 🔐 **Built-In Authentication**: Out-of-the-box auth support, ensuring only authorized users can access tools. 32 | - 🛠️ **Decoupled Tool Creation**: In an enterprise setting, decouple the creation of specialized tools (like data retrieval from specific knowledge sources) from the agent configuration. 33 | - ⚙️ **Works with LangChain tools**: You can integrate existing LangChain tools with minimal effort. 34 | 35 | ## Installation 36 | 37 | ```bash 38 | pip install universal-tool-server open-tool-client 39 | ``` 40 | 41 | ## Example Usage 42 | 43 | ### Server 44 | 45 | Add a server.py file to your project and define your tools with type hints. 46 | 47 | ```python 48 | from typing import Annotated 49 | from starlette.requests import Request 50 | 51 | from universal_tool_server.tools import InjectedRequest 52 | from universal_tool_server import Server, Auth 53 | 54 | app = Server() 55 | auth = Auth() 56 | app.add_auth(auth) 57 | 58 | 59 | @auth.authenticate 60 | async def authenticate(authorization: str) -> dict: 61 | """Authenticate incoming requests.""" 62 | api_key = authorization 63 | 64 | # Replace this with actual authentication logic. 65 | api_key_to_user = { 66 | "1": {"permissions": ["authenticated", "group1"], "identity": "some-user"}, 67 | "2": {"permissions": ["authenticated", "group2"], "identity": "another-user"}, 68 | } 69 | # This is just an example. You should replace this with an actual 70 | # implementation. 71 | if not api_key or api_key not in api_key_to_user: 72 | raise auth.exceptions.HTTPException(detail="Not authorized") 73 | return api_key_to_user[api_key] 74 | 75 | 76 | # Define tools 77 | 78 | @app.add_tool(permissions=["group1"]) 79 | async def echo(msg: str) -> str: 80 | """Echo a message.""" 81 | return msg + "!" 82 | 83 | 84 | # Tool that has access to the request object 85 | @app.add_tool(permissions=["authenticated"]) 86 | async def who_am_i(request: Annotated[Request, InjectedRequest]) -> str: 87 | """Get the user identity.""" 88 | return request.user.identity 89 | 90 | 91 | # You can also expose existing LangChain tools! 92 | from langchain_core.tools import tool 93 | 94 | 95 | @tool() 96 | async def say_hello() -> str: 97 | """Say hello.""" 98 | return "Hello" 99 | 100 | 101 | # Add an existing LangChain tool to the server with permissions! 102 | app.add_tool(say_hello, permissions=["group2"]) 103 | ``` 104 | 105 | ### Client 106 | 107 | Add a client.py file to your project and define your client. 108 | 109 | ```python 110 | import asyncio 111 | 112 | from universal_tool_client import get_async_client 113 | 114 | 115 | async def main(): 116 | if len(sys.argv) < 2: 117 | print( 118 | "Usage: uv run client.py url of universal-tool-server (i.e. http://localhost:8080/)>" 119 | ) 120 | sys.exit(1) 121 | 122 | url = sys.argv[1] 123 | client = get_async_client(url=url) 124 | # Check server status 125 | print(await client.ok()) # "OK" 126 | print(await client.info()) # Server version and other information 127 | 128 | # List tools 129 | print(await client.tools.list()) # List of tools 130 | # Call a tool 131 | print(await client.tools.call("add", {"x": 1, "y": 2})) # 3 132 | 133 | # Get as langchain tools 134 | select_tools = ["echo", "add"] 135 | tools = await client.tools.as_langchain_tools(select_tools) 136 | # Async 137 | print(await tools[0].ainvoke({"msg": "Hello"})) # "Hello!" 138 | print(await tools[1].ainvoke({"x": 1, "y": 3})) # 4 139 | 140 | 141 | if __name__ == "__main__": 142 | import sys 143 | 144 | asyncio.run(main()) 145 | ``` 146 | 147 | ### Sync Client 148 | 149 | If you need a synchronous client, you can use the `get_sync_client` function. 150 | 151 | ```python 152 | from universal_tool_client import get_sync_client 153 | ``` 154 | 155 | 156 | ### Using Existing LangChain Tools 157 | 158 | If you have existing LangChain tools, you can expose them via the API by using the `Server.tool` 159 | method which will add the tool to the server. 160 | 161 | This also gives you the option to add Authentication to an existing LangChain tool. 162 | 163 | ```python 164 | from open_tool_server import Server 165 | from langchain_core.tools import tool 166 | 167 | app = Server() 168 | 169 | # Say you have some existing langchain tool 170 | @tool() 171 | async def say_hello() -> str: 172 | """Say hello.""" 173 | return "Hello" 174 | 175 | # This is how you expose it via the API 176 | app.tool( 177 | say_hello, 178 | # You can include permissions if you're setting up Auth 179 | permissions=["group2"] 180 | ) 181 | ``` 182 | 183 | 184 | ### React Agent 185 | 186 | Here's an example of how you can use the Open Tool Server with a prebuilt LangGraph react agent. 187 | 188 | ```shell 189 | pip install langchain-anthropic langgraph 190 | ``` 191 | 192 | ```python 193 | import os 194 | 195 | from langchain_anthropic import ChatAnthropic 196 | from langgraph.prebuilt import create_react_agent 197 | 198 | from universal_tool_client import get_sync_client 199 | 200 | if "ANTHROPIC_API_KEY" not in os.environ: 201 | raise ValueError("Please set ANTHROPIC_API_KEY in the environment.") 202 | 203 | tool_server = get_sync_client( 204 | url=... # URL of the tool server 205 | # headers=... # If you enabled auth 206 | ) 207 | # Get tool definitions from the server 208 | tools = tool_server.tools.as_langchain_tools() 209 | print("Loaded tools:", tools) 210 | 211 | model = ChatAnthropic(model="claude-3-5-sonnet-20240620") 212 | agent = create_react_agent(model, tools=tools) 213 | print() 214 | 215 | user_message = "What is the temperature in Paris?" 216 | messages = agent.invoke({"messages": [{"role": "user", "content": user_message}]})[ 217 | "messages" 218 | ] 219 | 220 | for message in messages: 221 | message.pretty_print() 222 | ``` 223 | 224 | ### MCP SSE 225 | 226 | You can enable support for the MCP SSE protocol by passing `enable_mcp=True` to the Server constructor. 227 | 228 | > [!IMPORTANT] 229 | > Auth is not supported when using MCP SSE. So if you try to use auth and enable MCP, the server will raise an exception by design. 230 | 231 | ```python 232 | from universal_tool_server import Server 233 | 234 | app = Server(enable_mcp=True) 235 | 236 | 237 | @app.add_tool() 238 | async def echo(msg: str) -> str: 239 | """Echo a message.""" 240 | return msg + "!" 241 | ``` 242 | 243 | This will mount an MCP SSE app at /mcp/sse. You can use the MCP client to connect to the server. 244 | 245 | Use MCP client to connect to the server. **The url should be the same as the server url with `/mcp/sse` appended.** 246 | 247 | ```python 248 | from mcp import ClientSession 249 | 250 | from mcp.client.sse import sse_client 251 | 252 | async def main() -> None: 253 | # Please replace [host] with the actual host 254 | # IMPORTANT: Add /mcp/sse to the url! 255 | url = "[host]/mcp/sse" 256 | async with sse_client(url=url) as streams: 257 | async with ClientSession(streams[0], streams[1]) as session: 258 | await session.initialize() 259 | tools = await session.list_tools() 260 | print(tools) 261 | result = await session.call_tool("echo", {"msg": "Hello, world!"}) 262 | print(result) 263 | ``` 264 | 265 | ## Concepts 266 | 267 | ### Tool Definition 268 | 269 | A tool is a function that can be called by the client. It can be a simple function or a coroutine. The function signature should have type hints. The server will use these type hints to validate the input and output of the tool. 270 | 271 | ```python 272 | @app.add_tool() 273 | async def add(x: int, y: int) -> int: 274 | """Add two numbers.""" 275 | return x + y 276 | ``` 277 | 278 | #### Permissions 279 | 280 | You can specify `permissions` for a tool. The client must have the required permissions to call the tool. If the client does not have the required permissions, the server will return a 403 Forbidden error. 281 | 282 | ```python 283 | @app.add_tool(permissions=["group1"]) 284 | async def add(x: int, y: int) -> int: 285 | """Add two numbers.""" 286 | return x + y 287 | ``` 288 | 289 | A client must have **all** the required permissions to call the tool rather than a subset of the permissions. 290 | 291 | #### Injected Request 292 | 293 | A tool can request access to Starlette's `Request` object by using the `InjectedRequest` type hint. This can be useful for getting information about the request, such as the user's identity. 294 | 295 | ```python 296 | from typing import Annotated 297 | from universal_tool_server import InjectedRequest 298 | from starlette.requests import Request 299 | 300 | 301 | @app.add_tool(permissions=["group1"]) 302 | async def who_am_i(request: Annotated[Request, InjectedRequest]) -> str: 303 | """Return the user's identity""" 304 | # The `user` attribute can be used to retrieve the user object. 305 | # This object corresponds to the return value of the authentication function. 306 | return request.user.identity 307 | ``` 308 | 309 | 310 | ### Tool Discovery 311 | 312 | A client can list all available tools by calling the `tools.list` method. The server will return a list of tools with their names and descriptions. 313 | 314 | The client will only see tools for which they have the required permissions. 315 | 316 | ```python 317 | from universal_tool_client import get_async_client 318 | 319 | async def get_tools(): 320 | # Headers are entirely dependent on how you implement your authentication 321 | # (see Auth section) 322 | client = get_async_client(url="http://localhost:8080/", headers={"authorization": "api key"}) 323 | tools = await client.tools.list() 324 | # If you need langchain tools you can use the as_langchain_tools method 325 | langchain_tools = await client.tools.as_langchain_tools() 326 | # Do something 327 | ... 328 | ``` 329 | 330 | ### Auth 331 | 332 | You can add authentication to the server by defining an authentication function. 333 | 334 | **Tutorial** 335 | 336 | If you want to add realistic authentication to your server, you can follow the 3rd tutorial in the [Connecting an Authentication Provider](https://langchain-ai.github.io/langgraph/tutorials/auth/add_auth_server/) series for 337 | LangGraph Platform. It's a separate project, but the tutorial has useful information for setting up authentication in your server. 338 | 339 | #### Auth.authenticate 340 | 341 | The authentication function is a coroutine that can request any of the following parameters: 342 | 343 | | Parameter | Description | 344 | |-----------------|-----------------------------------------------------------------------------------------------------------------------------------| 345 | | `request` | The HTTP request object that encapsulates all details of the incoming client request, including metadata and routing info. | 346 | | `authorization` | A token or set of credentials used to authenticate the requestor and ensure secure access to the API or resource. | 347 | | `headers` | A dictionary of HTTP headers providing essential metadata (e.g., content type, encoding, user-agent) associated with the request. | 348 | | `body` | The payload of the request containing the data sent by the client, which may be formatted as JSON, XML, or form data. | 349 | 350 | The function should either: 351 | 352 | 1. Return a user object if the request is authenticated. 353 | 2. Raise an `auth.exceptions.HTTPException` if the request cannot be authenticated. 354 | 355 | ```python 356 | from universal_tool_server import Auth 357 | 358 | auth = Auth() 359 | 360 | @auth.authenticate 361 | async def authenticate(headers: dict[bytes, bytes]) -> dict: 362 | """Authenticate incoming requests.""" 363 | is_authenticated = ... # Your authentication logic here 364 | if not is_authenticated: 365 | raise auth.exceptions.HTTPException(detail="Not authorized") 366 | 367 | return { 368 | "identity": "some-user", 369 | "permissions": ["authenticated", "group1"], 370 | # Add any other user information here 371 | "foo": "bar", 372 | } 373 | ``` 374 | 375 | 376 | ## Awesome Servers 377 | 378 | * LangChain's [example tool server](https://github.com/langchain-ai/example-tool-server) with example tool to access github, hackernews, reddit. 379 | 380 | 381 | Would like to contribute your server to this list? Open a PR! 382 | -------------------------------------------------------------------------------- /examples/Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: all lint format 2 | 3 | # Default target executed when no arguments are given to make. 4 | all: help 5 | 6 | ###################### 7 | # LINTING AND FORMATTING 8 | ###################### 9 | 10 | # Define a variable for Python and notebook files. 11 | lint format: PYTHON_FILES=. 12 | lint_diff format_diff: PYTHON_FILES=$(shell git diff --relative=. --name-only --diff-filter=d master | grep -E '\.py$$|\.ipynb$$') 13 | 14 | lint lint_diff: 15 | [ "$(PYTHON_FILES)" = "" ] || uv run ruff format $(PYTHON_FILES) --diff 16 | [ "$(PYTHON_FILES)" = "" ] || uv run ruff check $(PYTHON_FILES) --diff 17 | # [ "$(PYTHON_FILES)" = "" ] || uv run mypy $(PYTHON_FILES) 18 | 19 | format format_diff: 20 | [ "$(PYTHON_FILES)" = "" ] || uv run ruff format $(PYTHON_FILES) 21 | [ "$(PYTHON_FILES)" = "" ] || uv run ruff check --fix $(PYTHON_FILES) 22 | 23 | 24 | ###################### 25 | # HELP 26 | ###################### 27 | 28 | help: 29 | @echo '====================' 30 | @echo '-- LINTING --' 31 | @echo 'format - run code formatters' 32 | @echo 'lint - run linters' 33 | -------------------------------------------------------------------------------- /examples/README.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/langchain-ai/universal-tool-server/9f9b17a3e3e1f77eb58b3d3455ba4ba9c22084a2/examples/README.md -------------------------------------------------------------------------------- /examples/auth/async_client.py: -------------------------------------------------------------------------------- 1 | """Example of using the async client to call universal-tool-server with auth.""" 2 | 3 | import asyncio 4 | 5 | from universal_tool_client import get_async_client 6 | 7 | 8 | async def main() -> None: 9 | if len(sys.argv) < 2: 10 | print( 11 | "Usage: uv run client.py url of universal-tool-server (i.e. http://localhost:8080/)>" 12 | ) 13 | sys.exit(1) 14 | 15 | url = sys.argv[1] 16 | 17 | print("\n--- Results for unauthenticated user ---\n") 18 | # A client with no credentials will get a 401 19 | client = get_async_client(url=url) 20 | 21 | try: 22 | print(await client.tools.list()) 23 | except Exception as e: 24 | print(f"Error: {e}") 25 | 26 | print('\n--- Results for x-api-key="1" ---\n') 27 | # As some-user 28 | client = get_async_client(url=url, headers={"x-api-key": "1"}) 29 | print("User: some-user has access to the following tools:") 30 | tools = await client.tools.list() 31 | for tool in tools: 32 | print(tool) 33 | # Call a tool 34 | who_am_i = await client.tools.call("who_am_i", {}) 35 | print(f"Result of calling who_am_i: {who_am_i}") 36 | 37 | print('\n--- Results for x-api-key="2" ---\n') 38 | # As another-user 39 | client = get_async_client(url=url, headers={"x-api-key": "2"}) 40 | print("User: another-user has access to the following tools:") 41 | for tool in await client.tools.list(): 42 | print(tool) 43 | who_am_i = await client.tools.call("who_am_i", {}) 44 | print(f"Result of calling who_am_i: {who_am_i}") 45 | 46 | 47 | if __name__ == "__main__": 48 | import sys 49 | 50 | asyncio.run(main()) 51 | -------------------------------------------------------------------------------- /examples/auth/server.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | from typing import Annotated 3 | 4 | from starlette.requests import Request 5 | 6 | from universal_tool_server import Auth, Server 7 | from universal_tool_server.tools import InjectedRequest 8 | 9 | app = Server() 10 | auth = Auth() 11 | 12 | 13 | @auth.authenticate 14 | async def authenticate(headers: dict[bytes, bytes]) -> dict: 15 | """Authenticate incoming requests.""" 16 | # Replace this with actual authentication logic. 17 | api_key = headers.get(b"x-api-key") 18 | 19 | api_key_to_user = { 20 | b"1": {"permissions": ["authenticated", "group1"], "identity": "some-user"}, 21 | b"2": {"permissions": ["authenticated", "group2"], "identity": "another-user"}, 22 | } 23 | 24 | if not api_key or api_key not in api_key_to_user: 25 | raise auth.exceptions.HTTPException(detail="Not authorized") 26 | return api_key_to_user[api_key] 27 | 28 | 29 | # At the moment this has to be done after registering the authenticate handler. 30 | app.add_auth(auth) 31 | 32 | 33 | @app.add_tool(permissions=["group1"]) 34 | async def echo(msg: str) -> str: 35 | """Echo a message.""" 36 | return msg + "!" 37 | 38 | 39 | @app.add_tool(permissions=["group2"]) 40 | async def say_hello() -> str: 41 | """Say hello.""" 42 | return "Hello" 43 | 44 | 45 | @app.add_tool(permissions=["authenticated"]) 46 | async def who_am_i(request: Annotated[Request, InjectedRequest]) -> str: 47 | """Get the user identity.""" 48 | return request.user.identity 49 | 50 | 51 | if __name__ == "__main__": 52 | import uvicorn 53 | 54 | uvicorn.run("__main__:app", host="127.0.0.1", port=8002, reload=True) 55 | -------------------------------------------------------------------------------- /examples/mcp/client.py: -------------------------------------------------------------------------------- 1 | """MCP client to test talking with the MCP app in the langchain-tools-server""" 2 | 3 | import asyncio 4 | 5 | from mcp import ClientSession 6 | from mcp.client.sse import sse_client 7 | 8 | 9 | async def main(): 10 | if len(sys.argv) < 2: 11 | print( 12 | "Usage: uv run client.py " 13 | ) 14 | sys.exit(1) 15 | 16 | url = sys.argv[1] 17 | 18 | if "mcp" not in url and "sse" not in url: 19 | raise ValueError("Use url format of [host]/mcp/sse") 20 | 21 | async with sse_client(url=url) as streams: 22 | async with ClientSession(streams[0], streams[1]) as session: 23 | await session.initialize() 24 | tools = await session.list_tools() 25 | print(tools) 26 | result = await session.call_tool("echo", {"msg": "Hello, world!"}) 27 | print(result) 28 | 29 | 30 | if __name__ == "__main__": 31 | import sys 32 | 33 | asyncio.run(main()) 34 | -------------------------------------------------------------------------------- /examples/mcp/server.py: -------------------------------------------------------------------------------- 1 | from universal_tool_server import Server 2 | 3 | app = Server(enable_mcp=True) 4 | 5 | 6 | @app.add_tool() 7 | async def echo(msg: str) -> str: 8 | """Echo a message.""" 9 | return msg + "!" 10 | 11 | 12 | @app.add_tool 13 | async def add(x: int, y: int) -> int: 14 | """Add two numbers.""" 15 | return x + y 16 | 17 | 18 | @app.add_tool() 19 | async def say_hello() -> str: 20 | """Say hello.""" 21 | return "Hello" 22 | 23 | 24 | if __name__ == "__main__": 25 | import uvicorn 26 | 27 | uvicorn.run("__main__:app", reload=True, port=8002) 28 | -------------------------------------------------------------------------------- /examples/pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "examples" 3 | version = "0.1.0" 4 | description = "Env for running examples." 5 | readme = "README.md" 6 | requires-python = ">=3.12" 7 | dependencies = [ 8 | "langchain-anthropic>=0.3.9", 9 | "langgraph>=0.3.5", 10 | "langgraph-prebuilt>=0.1.2", 11 | "universal-tool-client", 12 | "universal-tool-server", 13 | ] 14 | 15 | 16 | [dependency-groups] 17 | test = [ 18 | "ruff>=0.9.9", 19 | ] 20 | 21 | [tool.uv.sources] 22 | universal-tool-server = { path = "../libs/server" } 23 | universal-tool-client = { path = "../libs/sdk-py" } 24 | -------------------------------------------------------------------------------- /examples/react-agent/agent.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | 4 | from langchain_anthropic import ChatAnthropic 5 | from langgraph.prebuilt import create_react_agent 6 | 7 | from universal_tool_client import get_sync_client 8 | 9 | if "ANTHROPIC_API_KEY" not in os.environ: 10 | raise ValueError("Please set ANTHROPIC_API_KEY in the environment.") 11 | 12 | tool_server = get_sync_client( 13 | url="http://localhost:8002", 14 | # headers=... # If you enabled auth 15 | ) 16 | # Get tool definitions from the server 17 | tools = tool_server.tools.as_langchain_tools() 18 | print("Loaded tools:", tools) 19 | 20 | model = ChatAnthropic(model="claude-3-5-sonnet-20240620") 21 | agent = create_react_agent(model, tools=tools) 22 | print() 23 | 24 | user_message = "What is the temperature in Paris?" 25 | messages = agent.invoke({"messages": [{"role": "user", "content": user_message}]})[ 26 | "messages" 27 | ] 28 | 29 | for message in messages: 30 | message.pretty_print() 31 | -------------------------------------------------------------------------------- /examples/react-agent/server.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | from universal_tool_server import Server 3 | 4 | app = Server() 5 | 6 | 7 | @app.add_tool 8 | async def get_temperature(city: str) -> str: 9 | """Get the temperature in the given city.""" 10 | return "The temperature in {} is 25C".format(city) 11 | 12 | 13 | @app.add_tool() 14 | async def get_time(city: str) -> str: 15 | """Get the current local time in the given city.""" 16 | return "The current local time in {} is 12:34 PM".format(city) 17 | 18 | 19 | if __name__ == "__main__": 20 | import uvicorn 21 | 22 | uvicorn.run("__main__:app", reload=True, port=8002) 23 | -------------------------------------------------------------------------------- /examples/simple/async_client.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | from universal_tool_client import get_async_client 4 | 5 | 6 | async def main(): 7 | if len(sys.argv) < 2: 8 | print( 9 | "Usage: uv run client.py url of universal-tool-server (i.e. http://localhost:8080/)>" 10 | ) 11 | sys.exit(1) 12 | 13 | url = sys.argv[1] 14 | client = get_async_client(url=url) 15 | # Check server status 16 | print(await client.ok()) # "OK" 17 | print(await client.info()) # Server version and other information 18 | 19 | # List tools 20 | print(await client.tools.list()) # List of tools 21 | # Call a tool 22 | print(await client.tools.call("add", {"x": 1, "y": 2})) # 3 23 | 24 | # Get as langchain tools 25 | select_tools = ["echo", "add"] 26 | tools = await client.tools.as_langchain_tools(select_tools) 27 | # Async 28 | print(await tools[0].ainvoke({"msg": "Hello"})) # "Hello!" 29 | print(await tools[1].ainvoke({"x": 1, "y": 3})) # 4 30 | 31 | 32 | if __name__ == "__main__": 33 | import sys 34 | 35 | asyncio.run(main()) 36 | -------------------------------------------------------------------------------- /examples/simple/server.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | from universal_tool_server import Server 3 | from universal_tool_server.auth import Auth 4 | 5 | app = Server() 6 | auth = Auth() 7 | 8 | 9 | @app.add_tool() 10 | async def echo(msg: str) -> str: 11 | """Echo a message.""" 12 | return msg + "!" 13 | 14 | 15 | @app.add_tool 16 | async def add(x: int, y: int) -> int: 17 | """Add two numbers.""" 18 | return x + y 19 | 20 | 21 | @app.add_tool() 22 | async def say_hello() -> str: 23 | """Say hello.""" 24 | return "Hello" 25 | 26 | 27 | if __name__ == "__main__": 28 | import uvicorn 29 | 30 | uvicorn.run("__main__:app", host="127.0.0.1", port=8002, reload=True) 31 | -------------------------------------------------------------------------------- /libs/o2mcp/Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: all lint format test help 2 | 3 | # Default target executed when no arguments are given to make. 4 | all: help 5 | 6 | ###################### 7 | # TESTING AND COVERAGE 8 | ###################### 9 | 10 | # Define a variable for the test file path. 11 | TEST_FILE ?= tests/unit_tests 12 | 13 | test: 14 | uv run pytest --disable-socket --allow-unix-socket $(TEST_FILE) 15 | 16 | test_watch: 17 | uv run ptw . -- $(TEST_FILE) 18 | 19 | 20 | 21 | 22 | ###################### 23 | # LINTING AND FORMATTING 24 | ###################### 25 | 26 | # Define a variable for Python and notebook files. 27 | lint format: PYTHON_FILES=. 28 | lint_diff format_diff: PYTHON_FILES=$(shell git diff --relative=. --name-only --diff-filter=d master | grep -E '\.py$$|\.ipynb$$') 29 | 30 | lint lint_diff: 31 | [ "$(PYTHON_FILES)" = "" ] || uv run ruff format $(PYTHON_FILES) --diff 32 | [ "$(PYTHON_FILES)" = "" ] || uv run ruff check $(PYTHON_FILES) --diff 33 | # [ "$(PYTHON_FILES)" = "" ] || uv run mypy $(PYTHON_FILES) 34 | 35 | format format_diff: 36 | [ "$(PYTHON_FILES)" = "" ] || uv run ruff check --fix $(PYTHON_FILES) 37 | [ "$(PYTHON_FILES)" = "" ] || uv run ruff format $(PYTHON_FILES) 38 | 39 | ###################### 40 | # HELP 41 | ###################### 42 | 43 | help: 44 | @echo '====================' 45 | @echo '-- LINTING --' 46 | @echo 'format - run code formatters' 47 | @echo 'lint - run linters' 48 | @echo 'spell_check - run codespell on the project' 49 | @echo 'spell_fix - run codespell on the project and fix the errors' 50 | @echo '-- TESTS --' 51 | @echo 'coverage - run unit tests and generate coverage report' 52 | @echo 'test - run unit tests' 53 | @echo 'test TEST_FILE= - run all tests in file' 54 | @echo '-- DOCUMENTATION tasks are from the top-level Makefile --' 55 | 56 | -------------------------------------------------------------------------------- /libs/o2mcp/README.md: -------------------------------------------------------------------------------- 1 | ## Installation 2 | 3 | ```bash 4 | pip install o2mcp 5 | ``` 6 | -------------------------------------------------------------------------------- /libs/o2mcp/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/langchain-ai/universal-tool-server/9f9b17a3e3e1f77eb58b3d3455ba4ba9c22084a2/libs/o2mcp/__init__.py -------------------------------------------------------------------------------- /libs/o2mcp/o2mcp/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from __future__ import annotations 4 | 5 | import argparse 6 | import asyncio 7 | import json 8 | import os 9 | import sys 10 | from importlib import metadata 11 | from itertools import chain 12 | from typing import Any, Literal, Sequence 13 | 14 | from mcp import stdio_server 15 | from mcp.server.lowlevel import Server as MCPServer 16 | from mcp.server.sse import SseServerTransport 17 | from mcp.types import EmbeddedResource, ImageContent, TextContent, Tool 18 | from universal_tool_client import AsyncClient, get_async_client 19 | 20 | try: 21 | __version__ = metadata.version(__package__) 22 | except metadata.PackageNotFoundError: 23 | # Case where package metadata is not available. 24 | __version__ = "" 25 | 26 | 27 | # ANSI color codes 28 | class Colors: 29 | RED = "\033[91m" 30 | END = "\033[0m" 31 | 32 | 33 | def print_error(message: str) -> None: 34 | """Print an error message in red if terminal supports colors.""" 35 | # Check if stdout is a terminal and if the terminal supports colors 36 | if sys.stdout.isatty() and os.environ.get("TERM") != "dumb": 37 | print(f"{Colors.RED}Error: {message}{Colors.END}") 38 | else: 39 | print(f"Error: {message}") 40 | 41 | 42 | SPLASH = """\ 43 | ██████╗ ██████╗ ███╗ ███╗ ██████╗██████╗ 44 | ██╔═══██╗╚════██╗████╗ ████║██╔════╝██╔══██╗ 45 | ██║ ██║ █████╔╝██╔████╔██║██║ ██████╔╝ 46 | ██║ ██║██╔═══╝ ██║╚██╔╝██║██║ ██╔═══╝ 47 | ╚██████╔╝███████╗██║ ╚═╝ ██║╚██████╗██║ 48 | ╚═════╝ ╚══════╝╚═╝ ╚═╝ ╚═════╝╚═╝ 49 | """ 50 | 51 | 52 | def _convert_to_content( 53 | result: Any, 54 | ) -> Sequence[TextContent | ImageContent | EmbeddedResource]: 55 | """Convert a result to a sequence of content objects.""" 56 | # This code comes directly from the FastMCP server. 57 | # Imported here as it is a private function. 58 | import pydantic_core 59 | from mcp.server.fastmcp.utilities.types import Image 60 | from mcp.types import EmbeddedResource, ImageContent, TextContent 61 | 62 | if result is None: 63 | return [] 64 | 65 | if isinstance(result, (TextContent, ImageContent, EmbeddedResource)): 66 | return [result] 67 | 68 | if isinstance(result, Image): 69 | return [result.to_image_content()] 70 | 71 | if isinstance(result, (list, tuple)): 72 | return list(chain.from_iterable(_convert_to_content(item) for item in result)) 73 | 74 | if not isinstance(result, str): 75 | try: 76 | result = json.dumps(pydantic_core.to_jsonable_python(result)) 77 | except Exception: 78 | result = str(result) 79 | 80 | return [TextContent(type="text", text=result)] 81 | 82 | 83 | async def create_mcp_server( 84 | client: AsyncClient, *, tools: list[str] | None = None 85 | ) -> MCPServer: 86 | """Create MCP server. 87 | 88 | Args: 89 | client: AsyncClient instance. 90 | tools: If provided, only the tools on this list will be available. 91 | """ 92 | tools = tools or [] 93 | for tool in tools: 94 | if "@" in tool: 95 | raise NotImplementedError("Tool versions are not yet supported.") 96 | 97 | server = MCPServer(name="OTC-MCP Bridge") 98 | server_tools = await client.tools.list() 99 | 100 | latest_tool = {} 101 | 102 | for tool in server_tools: 103 | version = tool["version"] 104 | # version is semver 3 tuple 105 | version_tuple = tuple(map(int, version.split("."))) 106 | current_version = latest_tool.get(tool["name"], (0, 0, 0)) 107 | 108 | if version_tuple > current_version: 109 | latest_tool[tool["name"]] = version_tuple 110 | 111 | available_tools = [ 112 | Tool( 113 | name=tool["name"], 114 | description=tool["description"], 115 | inputSchema=tool["input_schema"], 116 | ) 117 | for tool in server_tools 118 | if tuple(map(int, tool["version"].split("."))) == latest_tool[tool["name"]] 119 | ] 120 | 121 | if tools: 122 | available_tools = [ 123 | available_tool 124 | for available_tool in available_tools 125 | if available_tool.name in tools 126 | ] 127 | 128 | @server.list_tools() 129 | async def list_tools() -> list[Tool]: 130 | """List available tools.""" 131 | # The original request object is not currently available in the MCP server. 132 | # We'll send a None for the request object. 133 | # This means that if Auth is enabled, the MCP endpoint will not 134 | # list any tools that require authentication. 135 | return available_tools 136 | 137 | @server.call_tool() 138 | async def call_tool( 139 | name: str, arguments: dict[str, Any] 140 | ) -> Sequence[TextContent | ImageContent | EmbeddedResource]: 141 | """Call a tool by name with arguments.""" 142 | # The original request object is not currently available in the MCP server. 143 | # We'll send a None for the request object. 144 | # This means that if Auth is enabled, the MCP endpoint will not 145 | # list any tools that require authentication. 146 | response = await client.tools.call(name, arguments) 147 | if not response["success"]: 148 | raise NotImplementedError( 149 | "Support for error messages is not yet implemented." 150 | ) 151 | return _convert_to_content(response["value"]) 152 | 153 | return server 154 | 155 | 156 | async def run_server_stdio(server: MCPServer) -> None: 157 | """Run the MCP server.""" 158 | async with stdio_server() as (read_stream, write_stream): 159 | await server.run( 160 | read_stream, 161 | write_stream, 162 | server.create_initialization_options(), 163 | ) 164 | 165 | 166 | async def run_starlette(server: MCPServer, *, host: str, port: int) -> None: 167 | """Run as a Starlette server exposing /sse endpoint.""" 168 | import uvicorn 169 | from starlette.applications import Starlette 170 | from starlette.requests import Request 171 | from starlette.routing import Mount, Route 172 | 173 | sse = SseServerTransport("/messages/") 174 | 175 | async def handle_sse(request: Request): 176 | async with sse.connect_sse( 177 | request.scope, request.receive, request._send 178 | ) as streams: 179 | await server.run( 180 | streams[0], streams[1], server.create_initialization_options() 181 | ) 182 | 183 | starlette_app = Starlette( 184 | routes=[ 185 | Route("/sse", endpoint=handle_sse), 186 | Mount("/messages/", app=sse.handle_post_message), 187 | ], 188 | ) 189 | 190 | config = uvicorn.Config( 191 | starlette_app, 192 | host=host, 193 | port=port, 194 | log_level="info", 195 | timeout_graceful_shutdown=1, 196 | ) 197 | uvicorn_server = uvicorn.Server(config) 198 | await uvicorn_server.serve() 199 | 200 | 201 | async def display_tools_table(*, url: str, headers: dict | None) -> None: 202 | """Connect to server and display available tools in a tabular format.""" 203 | client = get_async_client(url=url, headers=headers) 204 | print(f"\nConnecting to server at {url}...\n") 205 | 206 | try: 207 | server_tools = await client.tools.list() 208 | 209 | if not server_tools: 210 | print("No tools available.") 211 | return 212 | 213 | # Find the longest tool name for formatting 214 | max_name_length = max(len(tool["name"]) for tool in server_tools) 215 | 216 | # Print table header 217 | print(f"{'NAME':<{max_name_length + 2}}| DESCRIPTION") 218 | print(f"{'-' * (max_name_length + 2)}|{'-' * 70}") 219 | 220 | # Group tools by name and get the latest version of each 221 | latest_tools = {} 222 | 223 | for tool in server_tools: 224 | name = tool["name"] 225 | version = tool["version"] 226 | version_tuple = tuple(map(int, version.split("."))) 227 | 228 | if name not in latest_tools or version_tuple > latest_tools[name][0]: 229 | latest_tools[name] = (version_tuple, tool) 230 | 231 | # Sort tools by name 232 | for name in sorted(latest_tools.keys()): 233 | tool = latest_tools[name][1] 234 | 235 | description = tool["description"].strip() 236 | if not description: 237 | print(f"{tool['name']:<{max_name_length + 2}}| [No description]") 238 | print() 239 | continue 240 | 241 | # Split description by newlines to preserve original line breaks 242 | desc_lines = description.split("\n") 243 | first_line = True 244 | 245 | for line in desc_lines: 246 | if first_line: 247 | # Print first line with the tool name 248 | print(f"{tool['name']:<{max_name_length + 2}}| {line}") 249 | first_line = False 250 | else: 251 | # Print remaining lines with proper indentation relative to the column 252 | print(f"{' ' * (max_name_length + 2)}| {line}") 253 | 254 | # Add a small gap between tools 255 | print() 256 | 257 | except Exception as e: 258 | print_error(f"Failed to list tools: {str(e)}") 259 | sys.exit(1) 260 | 261 | 262 | async def run( 263 | *, 264 | url: str, 265 | headers: dict | None, 266 | tools: list[str] | None = None, 267 | mode: str = Literal["stdio", "sse"], 268 | sse_settings: dict | None = None, 269 | ) -> None: 270 | """Run the MCP server in stdio mode.""" 271 | client = get_async_client(url=url, headers=headers) 272 | print() 273 | print() 274 | print(SPLASH) 275 | print() 276 | server = await create_mcp_server(client, tools=tools) 277 | print() 278 | print(f"Connected to {url}") 279 | 280 | if mode == "sse": 281 | sse_settings = sse_settings or {} 282 | port = sse_settings.get("port", 8000) 283 | host = sse_settings.get("host", "localhost") 284 | print(f"Running MCP server with SSE endpoint at http://{host}:{port}/sse") 285 | print() 286 | await run_starlette(server, host=host, port=port) 287 | else: 288 | print("* Running MCP server in stdio mode. Press CTRL+C to exit.") 289 | await run_server_stdio(server) 290 | 291 | 292 | def get_usage_examples() -> str: 293 | """Return usage examples for the command line interface.""" 294 | examples = """ 295 | Examples: 296 | # Connect to a Universal Tool Server with default settings 297 | o2mcp --url http://localhost:8000 298 | 299 | # Connect with authentication headers 300 | o2mcp --url http://localhost:8000 --headers '{"Authorization": "Bearer YOUR_TOKEN"}' 301 | 302 | # Connect and limit to specific tools 303 | o2mcp --url http://localhost:8000 --tools tool1 tool2 tool3 304 | 305 | # List available tools without starting the server 306 | o2mcp --url http://localhost:8000 --list-tools 307 | 308 | # Start the server in SSE mode 309 | o2mcp --url http://localhost:8000 --mode sse 310 | 311 | # Start the server in SSE mode with custom host and port 312 | o2mcp --url http://localhost:8000 --mode sse --host 0.0.0.0 --port 9000 313 | 314 | # Display version information 315 | o2mcp --version 316 | """ 317 | return examples 318 | 319 | 320 | def show_usage_examples() -> None: 321 | """Print usage examples for the command line interface.""" 322 | print(get_usage_examples()) 323 | 324 | 325 | def main() -> None: 326 | """Main entry point for the MCP Bridge.""" 327 | parser = argparse.ArgumentParser( 328 | description="MCP Bridge Server", 329 | epilog=get_usage_examples(), 330 | formatter_class=argparse.RawDescriptionHelpFormatter, 331 | ) 332 | parser.add_argument( 333 | "--url", type=str, help="URL of the Universal Tool Server (required)" 334 | ) 335 | parser.add_argument( 336 | "--headers", 337 | type=str, 338 | default=None, 339 | help="JSON encoded headers to include in requests", 340 | ) 341 | parser.add_argument( 342 | "--tools", 343 | type=str, 344 | nargs="*", 345 | help=( 346 | "List of tools to expose. If not specified, all available tools will be " 347 | "used" 348 | ), 349 | ) 350 | parser.add_argument( 351 | "--list-tools", action="store_true", help="List available tools and exit" 352 | ) 353 | parser.add_argument( 354 | "--version", action="store_true", help="Show version information and exit" 355 | ) 356 | parser.add_argument( 357 | "--mode", 358 | type=str, 359 | choices=["stdio", "sse"], 360 | default="stdio", 361 | help="Server mode: stdio (default) or sse", 362 | ) 363 | parser.add_argument( 364 | "--host", 365 | type=str, 366 | default="localhost", 367 | help="Host to bind the SSE server to (only used with --mode sse)", 368 | ) 369 | parser.add_argument( 370 | "--port", 371 | type=int, 372 | default=8000, 373 | help="Port to bind the SSE server to (only used with --mode sse)", 374 | ) 375 | # Show help and version if no arguments provided 376 | if len(sys.argv) == 1: 377 | print(f"MCP Bridge v{__version__}") 378 | parser.print_help() 379 | sys.exit(0) 380 | 381 | args = parser.parse_args() 382 | 383 | # Handle version request 384 | if args.version: 385 | print(f"MCP Bridge v{__version__}") 386 | sys.exit(0) 387 | 388 | # Check for required URL 389 | if not args.url: 390 | parser.print_help() 391 | print("\n") # Add extra space before error 392 | print_error("the --url argument is required") 393 | sys.exit(1) 394 | 395 | headers = None 396 | if args.headers: 397 | try: 398 | headers = json.loads(args.headers) 399 | except json.JSONDecodeError: 400 | parser.print_help() 401 | print("\n") # Add extra space before error 402 | print_error("--headers must be valid JSON") 403 | sys.exit(1) 404 | 405 | if args.list_tools: 406 | asyncio.run(display_tools_table(url=args.url, headers=headers)) 407 | else: 408 | sse_settings = None 409 | 410 | # Check if host or port are specified in stdio mode 411 | if args.mode == "stdio" and (args.host != "localhost" or args.port != 8000): 412 | print_error("--host and --port can only be used with --mode sse") 413 | sys.exit(1) 414 | 415 | if args.mode == "sse": 416 | sse_settings = { 417 | "host": args.host, 418 | "port": args.port, 419 | } 420 | asyncio.run( 421 | run( 422 | url=args.url, 423 | headers=headers, 424 | tools=args.tools, 425 | mode=args.mode, 426 | sse_settings=sse_settings, 427 | ) 428 | ) 429 | 430 | 431 | if __name__ == "__main__": 432 | main() 433 | -------------------------------------------------------------------------------- /libs/o2mcp/pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "o2mcp" 3 | version = "0.0.3" 4 | description = "MCP Bridge" 5 | readme = "README.md" 6 | requires-python = ">=3.10" 7 | dependencies = [ 8 | "orjson>=3.10.1", 9 | "httpx>=0.22.1", 10 | "mcp>=1.4.1", 11 | "universal-tool-client>=0.0.2", 12 | "uvicorn>=0.20.0", 13 | ] 14 | 15 | [project.scripts] 16 | o2mcp = "o2mcp:main" 17 | 18 | [dependency-groups] 19 | test = [ 20 | "pytest>=8.3.4", 21 | "pytest-asyncio>=0.25.3", 22 | "pytest-cov>=6.0.0", 23 | "pytest-mock>=3.14.0", 24 | "pytest-socket>=0.7.0", 25 | "pytest-timeout>=2.3.1", 26 | "ruff>=0.9.7", 27 | ] 28 | 29 | [project.urls] 30 | repository = "https://github.com/langchain-ai/universal-tool-server" 31 | 32 | 33 | [tool.pytest.ini_options] 34 | minversion = "8.0" 35 | # -ra: Report all extra test outcomes (passed, skipped, failed, etc.) 36 | # -q: Enable quiet mode for less cluttered output 37 | # -v: Enable verbose output to display detailed test names and statuses 38 | # --durations=5: Show the 10 slowest tests after the run (useful for performance tuning) 39 | addopts = "-ra -q -v --durations=5" 40 | testpaths = [ 41 | "tests", 42 | ] 43 | python_files = ["test_*.py"] 44 | python_functions = ["test_*"] 45 | asyncio_mode = "auto" 46 | asyncio_default_fixture_loop_scope = "function" 47 | 48 | 49 | [tool.ruff] 50 | line-length = 88 51 | target-version = "py310" 52 | 53 | [tool.ruff.lint] 54 | select = [ 55 | "E", # pycodestyle errors 56 | "W", # pycodestyle warnings 57 | "F", # pyflakes 58 | "I", # isort 59 | "B", # flake8-bugbear 60 | ] 61 | ignore = [ 62 | "E501" # line-length 63 | ] 64 | 65 | 66 | [tool.mypy] 67 | python_version = "3.11" 68 | warn_return_any = true 69 | warn_unused_configs = true 70 | disallow_untyped_defs = true 71 | check_untyped_defs = true 72 | 73 | 74 | [build-system] 75 | requires = ["hatchling"] 76 | build-backend = "hatchling.build" 77 | 78 | -------------------------------------------------------------------------------- /libs/sdk-py/Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: all lint format test help start_mcp test_integration 2 | 3 | # Default target executed when no arguments are given to make. 4 | all: help 5 | 6 | ###################### 7 | # TESTING AND COVERAGE 8 | ###################### 9 | 10 | # Define a variable for the test file path. 11 | TEST_FILE ?= tests/unit_tests 12 | 13 | test: 14 | uv run pytest --disable-socket --allow-unix-socket $(TEST_FILE) 15 | 16 | test_watch: 17 | uv run ptw . -- $(TEST_FILE) 18 | 19 | 20 | 21 | 22 | ###################### 23 | # LINTING AND FORMATTING 24 | ###################### 25 | 26 | # Define a variable for Python and notebook files. 27 | lint format: PYTHON_FILES=. 28 | lint_diff format_diff: PYTHON_FILES=$(shell git diff --relative=. --name-only --diff-filter=d master | grep -E '\.py$$|\.ipynb$$') 29 | 30 | lint lint_diff: 31 | [ "$(PYTHON_FILES)" = "" ] || uv run ruff format $(PYTHON_FILES) --diff 32 | [ "$(PYTHON_FILES)" = "" ] || uv run ruff check $(PYTHON_FILES) --diff 33 | # [ "$(PYTHON_FILES)" = "" ] || uv run mypy $(PYTHON_FILES) 34 | 35 | format format_diff: 36 | [ "$(PYTHON_FILES)" = "" ] || uv run ruff format $(PYTHON_FILES) 37 | [ "$(PYTHON_FILES)" = "" ] || uv run ruff check --fix $(PYTHON_FILES) 38 | 39 | 40 | generate_examples: 41 | uv run langgraph-gen examples/agentic_rag/spec.yml --language python 42 | uv run langgraph-gen examples/agentic_rag/spec.yml --language typescript 43 | uv run langgraph-gen examples/rag/spec.yml --language python 44 | uv run langgraph-gen examples/rag/spec.yml --language typescript 45 | 46 | 47 | ###################### 48 | # HELP 49 | ###################### 50 | 51 | help: 52 | @echo '====================' 53 | @echo '-- LINTING --' 54 | @echo 'format - run code formatters' 55 | @echo 'lint - run linters' 56 | @echo 'spell_check - run codespell on the project' 57 | @echo 'spell_fix - run codespell on the project and fix the errors' 58 | @echo '-- TESTS --' 59 | @echo 'coverage - run unit tests and generate coverage report' 60 | @echo 'test - run unit tests' 61 | @echo 'test TEST_FILE= - run all tests in file' 62 | @echo '-- DOCUMENTATION tasks are from the top-level Makefile --' 63 | 64 | -------------------------------------------------------------------------------- /libs/sdk-py/README.md: -------------------------------------------------------------------------------- 1 | > [!IMPORTANT] 2 | > This is a work in progress. The API is expected to change. 3 | 4 | # Universal Tool Server 5 | 6 | A dedicated tool server decouples the creation of specialized tools (e.g., for retrieving data from specific knowledge sources) from agent development. This separation enables different teams to contribute and manage tools independently. Agents can then be rapidly configured—by simply specifying a prompt and a set of accessible tools. This streamlined approach simplifies authentication and authorization and accelerates the deployment of agents into production. 7 | 8 | Users working in a local environment that need MCP, [can enable MCP support](#MCP-SSE). In comparison to [MCP](https://modelcontextprotocol.io/introduction), this specification uses stateless connection which makes it suitable for web deployment. 9 | 10 | ## Why 11 | 12 | - 🌐 **Stateless Web Deployment**: Deploy as a web server without the need for persistent connections, allowing easy autoscaling and load balancing. 13 | - 📡 **Simple REST Protocol**: Leverage a straightforward REST API. 14 | - 🔐 **Built-In Authentication**: Out-of-the-box auth support, ensuring only authorized users can access tools. 15 | - 🛠️ **Decoupled Tool Creation**: In an enterprise setting, decouple the creation of specialized tools (like data retrieval from specific knowledge sources) from the agent configuration. 16 | - ⚙️ **Works with LangChain tools**: You can integrate existing LangChain tools with minimal effort. 17 | 18 | ## Installation 19 | 20 | ```bash 21 | pip install universal-tool-server open-tool-client 22 | ``` 23 | 24 | ## Example Usage 25 | 26 | ### Server 27 | 28 | Add a server.py file to your project and define your tools with type hints. 29 | 30 | ```python 31 | from typing import Annotated 32 | from starlette.requests import Request 33 | 34 | from universal_tool_server.tools import InjectedRequest 35 | from universal_tool_server import Server, Auth 36 | 37 | app = Server() 38 | auth = Auth() 39 | app.add_auth(auth) 40 | 41 | 42 | @auth.authenticate 43 | async def authenticate(authorization: str) -> dict: 44 | """Authenticate incoming requests.""" 45 | api_key = authorization 46 | 47 | # Replace this with actual authentication logic. 48 | api_key_to_user = { 49 | "1": {"permissions": ["authenticated", "group1"], "identity": "some-user"}, 50 | "2": {"permissions": ["authenticated", "group2"], "identity": "another-user"}, 51 | } 52 | # This is just an example. You should replace this with an actual 53 | # implementation. 54 | if not api_key or api_key not in api_key_to_user: 55 | raise auth.exceptions.HTTPException(detail="Not authorized") 56 | return api_key_to_user[api_key] 57 | 58 | 59 | # Define tools 60 | 61 | @app.add_tool(permissions=["group1"]) 62 | async def echo(msg: str) -> str: 63 | """Echo a message.""" 64 | return msg + "!" 65 | 66 | 67 | # Tool that has access to the request object 68 | @app.add_tool(permissions=["authenticated"]) 69 | async def who_am_i(request: Annotated[Request, InjectedRequest]) -> str: 70 | """Get the user identity.""" 71 | return request.user.identity 72 | 73 | 74 | # You can also expose existing LangChain tools! 75 | from langchain_core.tools import tool 76 | 77 | 78 | @tool() 79 | async def say_hello() -> str: 80 | """Say hello.""" 81 | return "Hello" 82 | 83 | 84 | # Add an existing LangChain tool to the server with permissions! 85 | app.add_tool(say_hello, permissions=["group2"]) 86 | ``` 87 | 88 | ### Client 89 | 90 | Add a client.py file to your project and define your client. 91 | 92 | ```python 93 | import asyncio 94 | 95 | from universal_tool_client import get_async_client 96 | 97 | 98 | async def main(): 99 | if len(sys.argv) < 2: 100 | print( 101 | "Usage: uv run client.py url of universal-tool-server (i.e. http://localhost:8080/)>" 102 | ) 103 | sys.exit(1) 104 | 105 | url = sys.argv[1] 106 | client = get_async_client(url=url) 107 | # Check server status 108 | print(await client.ok()) # "OK" 109 | print(await client.info()) # Server version and other information 110 | 111 | # List tools 112 | print(await client.tools.list()) # List of tools 113 | # Call a tool 114 | print(await client.tools.call("add", {"x": 1, "y": 2})) # 3 115 | 116 | # Get as langchain tools 117 | select_tools = ["echo", "add"] 118 | tools = await client.tools.as_langchain_tools(select_tools) 119 | # Async 120 | print(await tools[0].ainvoke({"msg": "Hello"})) # "Hello!" 121 | print(await tools[1].ainvoke({"x": 1, "y": 3})) # 4 122 | 123 | 124 | if __name__ == "__main__": 125 | import sys 126 | 127 | asyncio.run(main()) 128 | ``` 129 | 130 | ### Sync Client 131 | 132 | If you need a synchronous client, you can use the `get_sync_client` function. 133 | 134 | ```python 135 | from universal_tool_client import get_sync_client 136 | ``` 137 | 138 | 139 | ### Using Existing LangChain Tools 140 | 141 | If you have existing LangChain tools, you can expose them via the API by using the `Server.tool` 142 | method which will add the tool to the server. 143 | 144 | This also gives you the option to add Authentication to an existing LangChain tool. 145 | 146 | ```python 147 | from open_tool_server import Server 148 | from langchain_core.tools import tool 149 | 150 | app = Server() 151 | 152 | # Say you have some existing langchain tool 153 | @tool() 154 | async def say_hello() -> str: 155 | """Say hello.""" 156 | return "Hello" 157 | 158 | # This is how you expose it via the API 159 | app.tool( 160 | say_hello, 161 | # You can include permissions if you're setting up Auth 162 | permissions=["group2"] 163 | ) 164 | ``` 165 | 166 | 167 | ### React Agent 168 | 169 | Here's an example of how you can use the Open Tool Server with a prebuilt LangGraph react agent. 170 | 171 | ```shell 172 | pip install langchain-anthropic langgraph 173 | ``` 174 | 175 | ```python 176 | import os 177 | 178 | from langchain_anthropic import ChatAnthropic 179 | from langgraph.prebuilt import create_react_agent 180 | 181 | from universal_tool_client import get_sync_client 182 | 183 | if "ANTHROPIC_API_KEY" not in os.environ: 184 | raise ValueError("Please set ANTHROPIC_API_KEY in the environment.") 185 | 186 | tool_server = get_sync_client( 187 | url=... # URL of the tool server 188 | # headers=... # If you enabled auth 189 | ) 190 | # Get tool definitions from the server 191 | tools = tool_server.tools.as_langchain_tools() 192 | print("Loaded tools:", tools) 193 | 194 | model = ChatAnthropic(model="claude-3-5-sonnet-20240620") 195 | agent = create_react_agent(model, tools=tools) 196 | print() 197 | 198 | user_message = "What is the temperature in Paris?" 199 | messages = agent.invoke({"messages": [{"role": "user", "content": user_message}]})[ 200 | "messages" 201 | ] 202 | 203 | for message in messages: 204 | message.pretty_print() 205 | ``` 206 | 207 | ### MCP SSE 208 | 209 | You can enable support for the MCP SSE protocol by passing `enable_mcp=True` to the Server constructor. 210 | 211 | > [!IMPORTANT] 212 | > Auth is not supported when using MCP SSE. So if you try to use auth and enable MCP, the server will raise an exception by design. 213 | 214 | ```python 215 | from universal_tool_server import Server 216 | 217 | app = Server(enable_mcp=True) 218 | 219 | 220 | @app.add_tool() 221 | async def echo(msg: str) -> str: 222 | """Echo a message.""" 223 | return msg + "!" 224 | ``` 225 | 226 | This will mount an MCP SSE app at /mcp/sse. You can use the MCP client to connect to the server. 227 | 228 | Use MCP client to connect to the server. **The url should be the same as the server url with `/mcp/sse` appended.** 229 | 230 | ```python 231 | from mcp import ClientSession 232 | 233 | from mcp.client.sse import sse_client 234 | 235 | async def main() -> None: 236 | # Please replace [host] with the actual host 237 | # IMPORTANT: Add /mcp/sse to the url! 238 | url = "[host]/mcp/sse" 239 | async with sse_client(url=url) as streams: 240 | async with ClientSession(streams[0], streams[1]) as session: 241 | await session.initialize() 242 | tools = await session.list_tools() 243 | print(tools) 244 | result = await session.call_tool("echo", {"msg": "Hello, world!"}) 245 | print(result) 246 | ``` 247 | 248 | ## Concepts 249 | 250 | ### Tool Definition 251 | 252 | A tool is a function that can be called by the client. It can be a simple function or a coroutine. The function signature should have type hints. The server will use these type hints to validate the input and output of the tool. 253 | 254 | ```python 255 | @app.add_tool() 256 | async def add(x: int, y: int) -> int: 257 | """Add two numbers.""" 258 | return x + y 259 | ``` 260 | 261 | #### Permissions 262 | 263 | You can specify `permissions` for a tool. The client must have the required permissions to call the tool. If the client does not have the required permissions, the server will return a 403 Forbidden error. 264 | 265 | ```python 266 | @app.add_tool(permissions=["group1"]) 267 | async def add(x: int, y: int) -> int: 268 | """Add two numbers.""" 269 | return x + y 270 | ``` 271 | 272 | A client must have **all** the required permissions to call the tool rather than a subset of the permissions. 273 | 274 | #### Injected Request 275 | 276 | A tool can request access to Starlette's `Request` object by using the `InjectedRequest` type hint. This can be useful for getting information about the request, such as the user's identity. 277 | 278 | ```python 279 | from typing import Annotated 280 | from universal_tool_server import InjectedRequest 281 | from starlette.requests import Request 282 | 283 | 284 | @app.add_tool(permissions=["group1"]) 285 | async def who_am_i(request: Annotated[Request, InjectedRequest]) -> str: 286 | """Return the user's identity""" 287 | # The `user` attribute can be used to retrieve the user object. 288 | # This object corresponds to the return value of the authentication function. 289 | return request.user.identity 290 | ``` 291 | 292 | 293 | ### Tool Discovery 294 | 295 | A client can list all available tools by calling the `tools.list` method. The server will return a list of tools with their names and descriptions. 296 | 297 | The client will only see tools for which they have the required permissions. 298 | 299 | ```python 300 | from universal_tool_client import get_async_client 301 | 302 | async def get_tools(): 303 | # Headers are entirely dependent on how you implement your authentication 304 | # (see Auth section) 305 | client = get_async_client(url="http://localhost:8080/", headers={"authorization": "api key"}) 306 | tools = await client.tools.list() 307 | # If you need langchain tools you can use the as_langchain_tools method 308 | langchain_tools = await client.tools.as_langchain_tools() 309 | # Do something 310 | ... 311 | ``` 312 | 313 | ### Auth 314 | 315 | You can add authentication to the server by defining an authentication function. 316 | 317 | **Tutorial** 318 | 319 | If you want to add realistic authentication to your server, you can follow the 3rd tutorial in the [Connecting an Authentication Provider](https://langchain-ai.github.io/langgraph/tutorials/auth/add_auth_server/) series for 320 | LangGraph Platform. It's a separate project, but the tutorial has useful information for setting up authentication in your server. 321 | 322 | #### Auth.authenticate 323 | 324 | The authentication function is a coroutine that can request any of the following parameters: 325 | 326 | | Parameter | Description | 327 | |-----------------|-----------------------------------------------------------------------------------------------------------------------------------| 328 | | `request` | The HTTP request object that encapsulates all details of the incoming client request, including metadata and routing info. | 329 | | `authorization` | A token or set of credentials used to authenticate the requestor and ensure secure access to the API or resource. | 330 | | `headers` | A dictionary of HTTP headers providing essential metadata (e.g., content type, encoding, user-agent) associated with the request. | 331 | | `body` | The payload of the request containing the data sent by the client, which may be formatted as JSON, XML, or form data. | 332 | 333 | The function should either: 334 | 335 | 1. Return a user object if the request is authenticated. 336 | 2. Raise an `auth.exceptions.HTTPException` if the request cannot be authenticated. 337 | 338 | ```python 339 | from universal_tool_server import Auth 340 | 341 | auth = Auth() 342 | 343 | @auth.authenticate 344 | async def authenticate(headers: dict[bytes, bytes]) -> dict: 345 | """Authenticate incoming requests.""" 346 | is_authenticated = ... # Your authentication logic here 347 | if not is_authenticated: 348 | raise auth.exceptions.HTTPException(detail="Not authorized") 349 | 350 | return { 351 | "identity": "some-user", 352 | "permissions": ["authenticated", "group1"], 353 | # Add any other user information here 354 | "foo": "bar", 355 | } 356 | ``` 357 | 358 | 359 | ## Awesome Servers 360 | 361 | * LangChain's [example tool server](https://github.com/langchain-ai/example-tool-server) with example tool to access github, hackernews, reddit. 362 | 363 | 364 | Would like to contribute your server to this list? Open a PR! 365 | -------------------------------------------------------------------------------- /libs/sdk-py/pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "universal-tool-client" 3 | version = "0.0.2" 4 | description = "Universal Tool Client SDK (Python)" 5 | readme = "README.md" 6 | requires-python = ">=3.10" 7 | dependencies = [ 8 | "orjson>=3.10.1", 9 | "httpx>=0.22.1", 10 | "langchain-core>=0.3.0", 11 | ] 12 | 13 | [dependency-groups] 14 | test = [ 15 | "pytest>=8.3.4", 16 | "pytest-asyncio>=0.25.3", 17 | "pytest-cov>=6.0.0", 18 | "pytest-mock>=3.14.0", 19 | "pytest-socket>=0.7.0", 20 | "pytest-timeout>=2.3.1", 21 | "ruff>=0.9.7", 22 | ] 23 | 24 | [project.urls] 25 | repository = "https://github.com/langchain-ai/universal-tool-server" 26 | 27 | 28 | [tool.pytest.ini_options] 29 | minversion = "8.0" 30 | # -ra: Report all extra test outcomes (passed, skipped, failed, etc.) 31 | # -q: Enable quiet mode for less cluttered output 32 | # -v: Enable verbose output to display detailed test names and statuses 33 | # --durations=5: Show the 10 slowest tests after the run (useful for performance tuning) 34 | addopts = "-ra -q -v --durations=5" 35 | testpaths = [ 36 | "tests", 37 | ] 38 | python_files = ["test_*.py"] 39 | python_functions = ["test_*"] 40 | asyncio_mode = "auto" 41 | asyncio_default_fixture_loop_scope = "function" 42 | 43 | 44 | [tool.ruff] 45 | line-length = 88 46 | target-version = "py310" 47 | 48 | [tool.ruff.lint] 49 | select = [ 50 | "E", # pycodestyle errors 51 | "W", # pycodestyle warnings 52 | "F", # pyflakes 53 | "I", # isort 54 | "B", # flake8-bugbear 55 | ] 56 | ignore = [ 57 | "E501" # line-length 58 | ] 59 | 60 | 61 | [tool.mypy] 62 | python_version = "3.11" 63 | warn_return_any = true 64 | warn_unused_configs = true 65 | disallow_untyped_defs = true 66 | check_untyped_defs = true 67 | 68 | 69 | [build-system] 70 | requires = ["hatchling"] 71 | build-backend = "hatchling.build" 72 | 73 | -------------------------------------------------------------------------------- /libs/sdk-py/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/langchain-ai/universal-tool-server/9f9b17a3e3e1f77eb58b3d3455ba4ba9c22084a2/libs/sdk-py/tests/__init__.py -------------------------------------------------------------------------------- /libs/sdk-py/tests/unit_tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/langchain-ai/universal-tool-server/9f9b17a3e3e1f77eb58b3d3455ba4ba9c22084a2/libs/sdk-py/tests/unit_tests/__init__.py -------------------------------------------------------------------------------- /libs/sdk-py/tests/unit_tests/test_sdk.py: -------------------------------------------------------------------------------- 1 | """Python SDK tests are directly in the server code.""" 2 | 3 | 4 | def test_attempt_import() -> None: 5 | """Simple test to just verify that the module can be imported.""" 6 | from universal_tool_client import ( # noqa: F401 7 | AsyncClient, 8 | SyncClient, 9 | get_async_client, 10 | get_sync_client, 11 | ) 12 | -------------------------------------------------------------------------------- /libs/sdk-py/universal_tool_client/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import asyncio 4 | import logging 5 | import sys 6 | from importlib import metadata 7 | from typing import ( 8 | TYPE_CHECKING, 9 | Any, 10 | Awaitable, 11 | Callable, 12 | Dict, 13 | List, 14 | Optional, 15 | Sequence, 16 | ) 17 | 18 | import httpx 19 | import orjson 20 | from httpx._types import QueryParamTypes 21 | 22 | if TYPE_CHECKING: 23 | from langchain_core.tools import BaseTool 24 | 25 | try: 26 | __version__ = metadata.version(__package__) 27 | except metadata.PackageNotFoundError: 28 | # Case where package metadata is not available. 29 | __version__ = "" 30 | 31 | logger = logging.getLogger(__name__) 32 | 33 | PROTOCOL = "urn:oxp:1.0" 34 | 35 | def _get_headers(custom_headers: Optional[dict[str, str]]) -> dict[str, str]: 36 | """Combine api_key and custom user-provided headers.""" 37 | custom_headers = custom_headers or {} 38 | headers = { 39 | "User-Agent": f"universal-tool-sdk-py/{__version__}", 40 | **custom_headers, 41 | } 42 | return headers 43 | 44 | 45 | def _decode_json(r: httpx.Response) -> Any: 46 | body = r.read() 47 | return orjson.loads(body if body else None) 48 | 49 | 50 | def _encode_json(json: Any) -> tuple[dict[str, str], bytes]: 51 | body = orjson.dumps( 52 | json, 53 | _orjson_default, 54 | orjson.OPT_SERIALIZE_NUMPY | orjson.OPT_NON_STR_KEYS, 55 | ) 56 | content_length = str(len(body)) 57 | content_type = "application/json" 58 | headers = {"Content-Length": content_length, "Content-Type": content_type} 59 | return headers, body 60 | 61 | 62 | def _orjson_default(obj: Any) -> Any: 63 | if hasattr(obj, "model_dump") and callable(obj.model_dump): 64 | return obj.model_dump() 65 | elif hasattr(obj, "dict") and callable(obj.dict): 66 | return obj.dict() 67 | elif isinstance(obj, (set, frozenset)): 68 | return list(obj) 69 | else: 70 | raise TypeError(f"Object of type {type(obj)} is not JSON serializable") 71 | 72 | 73 | async def _aencode_json(json: Any) -> tuple[dict[str, str], bytes]: 74 | """Encode JSON.""" 75 | if json is None: 76 | return {}, None 77 | body = await asyncio.get_running_loop().run_in_executor( 78 | None, 79 | orjson.dumps, 80 | json, 81 | _orjson_default, 82 | orjson.OPT_SERIALIZE_NUMPY | orjson.OPT_NON_STR_KEYS, 83 | ) 84 | content_length = str(len(body)) 85 | content_type = "application/json" 86 | headers = {"Content-Length": content_length, "Content-Type": content_type} 87 | return headers, body 88 | 89 | 90 | async def _adecode_json(r: httpx.Response) -> Any: 91 | """Decode JSON.""" 92 | body = await r.aread() 93 | return ( 94 | await asyncio.get_running_loop().run_in_executor(None, orjson.loads, body) 95 | if body 96 | else None 97 | ) 98 | 99 | 100 | class AsyncHttpClient: 101 | """Handle async requests to the LangGraph API. 102 | 103 | Adds additional error messaging & content handling above the 104 | provided httpx client. 105 | 106 | Attributes: 107 | client (httpx.AsyncClient): Underlying HTTPX async client. 108 | """ 109 | 110 | def __init__(self, client: httpx.AsyncClient) -> None: 111 | self.client = client 112 | 113 | async def get(self, path: str, *, params: Optional[QueryParamTypes] = None) -> Any: 114 | """Send a GET request.""" 115 | r = await self.client.get(path, params=params) 116 | try: 117 | r.raise_for_status() 118 | except httpx.HTTPStatusError as e: 119 | body = (await r.aread()).decode() 120 | if sys.version_info >= (3, 11): 121 | e.add_note(body) 122 | else: 123 | logger.error(f"Error from universal-tool-server: {body}", exc_info=e) 124 | raise e 125 | return await _adecode_json(r) 126 | 127 | async def post(self, path: str, *, json: Optional[dict]) -> Any: 128 | """Send a POST request.""" 129 | if json is not None: 130 | headers, content = await _aencode_json(json) 131 | else: 132 | headers, content = {}, b"" 133 | r = await self.client.post(path, headers=headers, content=content) 134 | try: 135 | r.raise_for_status() 136 | except httpx.HTTPStatusError as e: 137 | body = (await r.aread()).decode() 138 | if sys.version_info >= (3, 11): 139 | e.add_note(body) 140 | else: 141 | logger.error(f"Error from universal-tool-server: {body}", exc_info=e) 142 | raise e 143 | return await _adecode_json(r) 144 | 145 | async def put(self, path: str, *, json: dict) -> Any: 146 | """Send a PUT request.""" 147 | headers, content = await _aencode_json(json) 148 | r = await self.client.put(path, headers=headers, content=content) 149 | try: 150 | r.raise_for_status() 151 | except httpx.HTTPStatusError as e: 152 | body = (await r.aread()).decode() 153 | if sys.version_info >= (3, 11): 154 | e.add_note(body) 155 | else: 156 | logger.error(f"Error from universal-tool-server: {body}", exc_info=e) 157 | raise e 158 | return await _adecode_json(r) 159 | 160 | async def patch(self, path: str, *, json: dict) -> Any: 161 | """Send a PATCH request.""" 162 | headers, content = await _aencode_json(json) 163 | r = await self.client.patch(path, headers=headers, content=content) 164 | try: 165 | r.raise_for_status() 166 | except httpx.HTTPStatusError as e: 167 | body = (await r.aread()).decode() 168 | if sys.version_info >= (3, 11): 169 | e.add_note(body) 170 | else: 171 | logger.error(f"Error from universal-tool-server: {body}", exc_info=e) 172 | raise e 173 | return await _adecode_json(r) 174 | 175 | async def delete(self, path: str, *, json: Optional[Any] = None) -> None: 176 | """Send a DELETE request.""" 177 | r = await self.client.request("DELETE", path, json=json) 178 | try: 179 | r.raise_for_status() 180 | except httpx.HTTPStatusError as e: 181 | body = (await r.aread()).decode() 182 | if sys.version_info >= (3, 11): 183 | e.add_note(body) 184 | else: 185 | logger.error(f"Error from universal-tool-server: {body}", exc_info=e) 186 | raise e 187 | 188 | 189 | class SyncHttpClient: 190 | def __init__(self, client: httpx.Client) -> None: 191 | self.client = client 192 | 193 | def get(self, path: str, *, params: Optional[QueryParamTypes] = None) -> Any: 194 | """Send a GET request.""" 195 | r = self.client.get(path, params=params) 196 | try: 197 | r.raise_for_status() 198 | except httpx.HTTPStatusError as e: 199 | body = r.read().decode() 200 | if sys.version_info >= (3, 11): 201 | e.add_note(body) 202 | else: 203 | logger.error(f"Error from universal-tool-server: {body}", exc_info=e) 204 | raise e 205 | return _decode_json(r) 206 | 207 | def post(self, path: str, *, json: Optional[dict]) -> Any: 208 | """Send a POST request.""" 209 | if json is not None: 210 | headers, content = _encode_json(json) 211 | else: 212 | headers, content = {}, b"" 213 | r = self.client.post(path, headers=headers, content=content) 214 | try: 215 | r.raise_for_status() 216 | except httpx.HTTPStatusError as e: 217 | body = r.read().decode() 218 | if sys.version_info >= (3, 11): 219 | e.add_note(body) 220 | else: 221 | logger.error(f"Error from universal-tool-server: {body}", exc_info=e) 222 | raise e 223 | return _decode_json(r) 224 | 225 | def put(self, path: str, *, json: dict) -> Any: 226 | """Send a PUT request.""" 227 | headers, content = _encode_json(json) 228 | r = self.client.put(path, headers=headers, content=content) 229 | try: 230 | r.raise_for_status() 231 | except httpx.HTTPStatusError as e: 232 | body = r.read().decode() 233 | if sys.version_info >= (3, 11): 234 | e.add_note(body) 235 | else: 236 | logger.error(f"Error from universal-tool-server: {body}", exc_info=e) 237 | raise e 238 | return _decode_json(r) 239 | 240 | def patch(self, path: str, *, json: dict) -> Any: 241 | """Send a PATCH request.""" 242 | headers, content = _encode_json(json) 243 | r = self.client.patch(path, headers=headers, content=content) 244 | try: 245 | r.raise_for_status() 246 | except httpx.HTTPStatusError as e: 247 | body = r.read().decode() 248 | if sys.version_info >= (3, 11): 249 | e.add_note(body) 250 | else: 251 | logger.error(f"Error from universal-tool-server: {body}", exc_info=e) 252 | raise e 253 | return _decode_json(r) 254 | 255 | def delete(self, path: str, *, json: Optional[Any] = None) -> None: 256 | """Send a DELETE request.""" 257 | r = self.client.request("DELETE", path, json=json) 258 | try: 259 | r.raise_for_status() 260 | except httpx.HTTPStatusError as e: 261 | body = r.read().decode() 262 | if sys.version_info >= (3, 11): 263 | e.add_note(body) 264 | else: 265 | logger.error(f"Error from universal-tool-server: {body}", exc_info=e) 266 | raise e 267 | 268 | 269 | ############ 270 | # PUBLIC API 271 | 272 | 273 | def get_async_client( 274 | *, 275 | url: Optional[str] = None, 276 | headers: Optional[dict[str, str]] = None, 277 | transport: Optional[httpx.AsyncBaseTransport] = None, 278 | ) -> AsyncClient: 279 | """Get instance. 280 | 281 | Args: 282 | url: The URL of the tool server. 283 | headers: Optional custom headers 284 | transport: Optional transport to use. 285 | 286 | Returns: 287 | AsyncClient: The top-level client for accessing the tool server. 288 | """ 289 | 290 | if url is None: 291 | url = "http://localhost:2424" 292 | 293 | if transport is None: 294 | transport = httpx.AsyncHTTPTransport(retries=5) 295 | 296 | client = httpx.AsyncClient( 297 | base_url=url, 298 | transport=transport, 299 | timeout=httpx.Timeout(connect=5, read=300, write=300, pool=5), 300 | headers=_get_headers(headers), 301 | ) 302 | return AsyncClient(client) 303 | 304 | 305 | def get_sync_client( 306 | *, 307 | url: Optional[str] = None, 308 | headers: Optional[dict[str, str]] = None, 309 | transport: Optional[httpx.AsyncBaseTransport] = None, 310 | ) -> SyncClient: 311 | """Get instance. 312 | 313 | Args: 314 | url: The URL of the tool server. 315 | headers: Optional custom headers 316 | transport: Optional transport to use. 317 | 318 | Returns: 319 | AsyncClient: The top-level client for accessing the tool server. 320 | """ 321 | 322 | if url is None: 323 | url = "http://localhost:2424" 324 | 325 | if transport is None: 326 | transport = httpx.HTTPTransport(retries=5) 327 | 328 | client = httpx.Client( 329 | base_url=url, 330 | transport=transport, 331 | timeout=httpx.Timeout(connect=5, read=300, write=300, pool=5), 332 | headers=_get_headers(headers), 333 | ) 334 | return SyncClient(client) 335 | 336 | 337 | class AsyncClient: 338 | """Top-level client for the tools server.""" 339 | 340 | def __init__(self, client: httpx.AsyncClient) -> None: 341 | """Initialize the client.""" 342 | self.http = AsyncHttpClient(client) 343 | self.tools = AsyncToolsClient(self.http) 344 | 345 | async def info(self) -> Any: 346 | return await self.http.get("/info") 347 | 348 | async def health(self) -> Any: 349 | return await self.http.get("/health") 350 | 351 | 352 | class SyncClient: 353 | """Top-level client for the tools server.""" 354 | 355 | def __init__(self, client: httpx.Client) -> None: 356 | """Initialize the client.""" 357 | self.http = SyncHttpClient(client) 358 | self.tools = SyncToolsClient(self.http) 359 | 360 | def info(self) -> Any: 361 | return self.http.get("/info") 362 | 363 | def health(self) -> Any: 364 | return self.http.get("/health") 365 | 366 | 367 | class AsyncToolsClient: 368 | """Tools API.""" 369 | 370 | def __init__(self, http: AsyncHttpClient) -> None: 371 | """Initialize the client.""" 372 | self.http = http 373 | 374 | async def list(self) -> Any: 375 | """List tools.""" 376 | return await self.http.get("/tools") 377 | 378 | async def call( 379 | self, 380 | tool_id: str, 381 | args: Dict[str, Any] | None = None, 382 | *, 383 | call_id: Optional[str] = None, 384 | ) -> Any: 385 | """Call a tool.""" 386 | payload = {"tool_id": tool_id} 387 | if args is not None: 388 | payload["input"] = args 389 | if call_id is not None: 390 | payload["call_id"] = call_id 391 | request = {"request": payload, "$schema": PROTOCOL} 392 | return await self.http.post("/tools/call", json=request) 393 | 394 | async def as_langchain_tools( 395 | self, *, tool_ids: Sequence[str] | None = None 396 | ) -> List[BaseTool]: 397 | """Load tools from the server. 398 | 399 | Args: 400 | tool_ids: If specified, will only load the selected tools. 401 | Otherwise, all tools will be loaded. 402 | 403 | Returns: 404 | a list of LangChain tools. 405 | """ 406 | try: 407 | from langchain_core.tools import StructuredTool 408 | except ImportError as e: 409 | raise ImportError( 410 | "To use this method, you must have langchain-core installed. " 411 | "You can install it with `pip install langchain-core`." 412 | ) from e 413 | 414 | available_tools = await self.list() 415 | 416 | available_tools_by_name = {tool["name"]: tool for tool in available_tools} 417 | 418 | if tool_ids is None: 419 | tool_ids = list(available_tools_by_name) 420 | 421 | if set(tool_ids) - set(available_tools_by_name): 422 | raise ValueError( 423 | f"Unknown tool names: {set(tool_ids) - set(available_tools_by_name)}" 424 | ) 425 | 426 | # The code below will create LangChain style tools by binding 427 | # tool metadata and the tool implementation together in a StructuredTool. 428 | def create_tool_caller(tool_name_: str) -> Callable[..., Awaitable[Any]]: 429 | """Create a tool caller.""" 430 | 431 | async def call_tool(**kwargs: Any) -> Any: 432 | """Call a tool.""" 433 | call_tool_result = await self.call(tool_name_, kwargs) 434 | if not call_tool_result["success"]: 435 | raise NotImplementedError( 436 | "An error occurred while calling the tool. " 437 | "The client does not yet support error handling." 438 | ) 439 | return call_tool_result["value"] 440 | 441 | return call_tool 442 | 443 | tools = [] 444 | 445 | for tool_id in tool_ids: 446 | tool_spec = available_tools_by_name[tool_id] 447 | 448 | tools.append( 449 | StructuredTool( 450 | name=tool_spec["name"], 451 | description=tool_spec["description"], 452 | args_schema=tool_spec["input_schema"], 453 | coroutine=create_tool_caller(tool_id), 454 | ) 455 | ) 456 | return tools 457 | 458 | 459 | class SyncToolsClient: 460 | """Tools API.""" 461 | 462 | def __init__(self, http: SyncHttpClient) -> None: 463 | """Initialize the client.""" 464 | self.http = http 465 | 466 | def list(self) -> Any: 467 | """List tools.""" 468 | return self.http.get("/tools") 469 | 470 | def call( 471 | self, 472 | tool_id: str, 473 | args: Dict[str, Any] | None = None, 474 | *, 475 | call_id: str | None = None, 476 | ) -> Any: 477 | """Call a tool.""" 478 | 479 | payload = {"tool_id": tool_id} 480 | if args is not None: 481 | payload["input"] = args 482 | if call_id is not None: 483 | payload["call_id"] = call_id 484 | request = { 485 | "$schema": PROTOCOL, 486 | "request": payload, 487 | } 488 | return self.http.post("/tools/call", json=request) 489 | 490 | def as_langchain_tools( 491 | self, *, tool_ids: Sequence[str] | None = None 492 | ) -> List[BaseTool]: 493 | """Load tools from the server. 494 | 495 | Args: 496 | tool_ids: If specified, will only load the selected tools. 497 | Otherwise, all tools will be loaded. 498 | 499 | Returns: 500 | a list of LangChain tools. 501 | """ 502 | try: 503 | from langchain_core.tools import StructuredTool 504 | except ImportError as e: 505 | raise ImportError( 506 | "To use this method, you must have langchain-core installed. " 507 | "You can install it with `pip install langchain-core`." 508 | ) from e 509 | 510 | available_tools = self.list() 511 | available_tools_by_name = {tool["name"]: tool for tool in available_tools} 512 | 513 | if tool_ids is None: 514 | tool_ids = list(available_tools_by_name) 515 | 516 | if set(tool_ids) - set(available_tools_by_name): 517 | raise ValueError( 518 | f"Unknown tool names: {set(tool_ids) - set(available_tools_by_name)}" 519 | ) 520 | 521 | # The code below will create LangChain style tools by binding 522 | # tool metadata and the tool implementation together in a StructuredTool. 523 | 524 | def create_tool_caller(tool_id: str) -> Callable[..., Any]: 525 | """Create a tool caller.""" 526 | 527 | def call_tool(**kwargs: Any) -> Any: 528 | """Call a tool.""" 529 | call_tool_result = self.call(tool_id, kwargs) 530 | if not call_tool_result["success"]: 531 | raise NotImplementedError( 532 | "An error occurred while calling the tool. " 533 | "The client does not yet support error handling." 534 | ) 535 | return call_tool_result["value"] 536 | 537 | return call_tool 538 | 539 | tools = [] 540 | 541 | for tool_name in tool_ids: 542 | tool_spec = available_tools_by_name[tool_name] 543 | 544 | tools.append( 545 | StructuredTool( 546 | name=tool_name, 547 | description=tool_spec["description"], 548 | args_schema=tool_spec["input_schema"], 549 | func=create_tool_caller(tool_name), 550 | ) 551 | ) 552 | return tools 553 | 554 | 555 | __all__ = [ 556 | "get_async_client", 557 | "get_sync_client", 558 | "AsyncClient", 559 | "SyncClient", 560 | ] 561 | -------------------------------------------------------------------------------- /libs/server/.ipynb_checkpoints/Untitled-checkpoint.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [], 3 | "metadata": {}, 4 | "nbformat": 4, 5 | "nbformat_minor": 5 6 | } 7 | -------------------------------------------------------------------------------- /libs/server/Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: all lint format test help start_mcp test_integration 2 | 3 | # Default target executed when no arguments are given to make. 4 | all: help 5 | 6 | ###################### 7 | # TESTING AND COVERAGE 8 | ###################### 9 | 10 | # Define a variable for the test file path. 11 | TEST_FILE ?= tests/unit_tests 12 | 13 | test: 14 | uv run pytest --disable-socket --allow-unix-socket $(TEST_FILE) 15 | 16 | test_watch: 17 | uv run ptw . -- $(TEST_FILE) 18 | 19 | .PHONY: test_integration 20 | 21 | test_integration: 22 | uv run uvicorn tests.integration.mcp_server:app --host localhost --port 8133 --reload & \ 23 | pid=$$!; \ 24 | trap "kill -9 $$pid" EXIT; \ 25 | while [ "$$(curl -s -o /dev/null -w '%{http_code}' http://localhost:8133/health)" != "200" ]; do \ 26 | sleep 0.2; \ 27 | done; \ 28 | MCP_SSE_URL="http://localhost:8133/mcp/sse" uv run pytest tests/integration 29 | 30 | start_mcp: 31 | # start mcp server for integration tsets 32 | uv run uvicorn tests.integration.mcp_server:app --host localhost --port 8133 --reload 33 | 34 | 35 | run_integration_test: 36 | uv run pytest tests/integration 37 | 38 | 39 | ###################### 40 | # LINTING AND FORMATTING 41 | ###################### 42 | 43 | # Define a variable for Python and notebook files. 44 | lint format: PYTHON_FILES=. 45 | lint_diff format_diff: PYTHON_FILES=$(shell git diff --relative=. --name-only --diff-filter=d master | grep -E '\.py$$|\.ipynb$$') 46 | 47 | lint lint_diff: 48 | [ "$(PYTHON_FILES)" = "" ] || uv run ruff format $(PYTHON_FILES) --diff 49 | [ "$(PYTHON_FILES)" = "" ] || uv run ruff check $(PYTHON_FILES) --diff 50 | # [ "$(PYTHON_FILES)" = "" ] || uv run mypy $(PYTHON_FILES) 51 | 52 | format format_diff: 53 | [ "$(PYTHON_FILES)" = "" ] || uv run ruff format $(PYTHON_FILES) 54 | [ "$(PYTHON_FILES)" = "" ] || uv run ruff check --fix $(PYTHON_FILES) 55 | 56 | 57 | generate_examples: 58 | uv run langgraph-gen examples/agentic_rag/spec.yml --language python 59 | uv run langgraph-gen examples/agentic_rag/spec.yml --language typescript 60 | uv run langgraph-gen examples/rag/spec.yml --language python 61 | uv run langgraph-gen examples/rag/spec.yml --language typescript 62 | 63 | 64 | ###################### 65 | # HELP 66 | ###################### 67 | 68 | help: 69 | @echo '====================' 70 | @echo '-- LINTING --' 71 | @echo 'format - run code formatters' 72 | @echo 'lint - run linters' 73 | @echo 'spell_check - run codespell on the project' 74 | @echo 'spell_fix - run codespell on the project and fix the errors' 75 | @echo '-- TESTS --' 76 | @echo 'coverage - run unit tests and generate coverage report' 77 | @echo 'test - run unit tests' 78 | @echo 'test TEST_FILE= - run all tests in file' 79 | @echo '-- DOCUMENTATION tasks are from the top-level Makefile --' 80 | 81 | -------------------------------------------------------------------------------- /libs/server/README.md: -------------------------------------------------------------------------------- 1 | > [!IMPORTANT] 2 | > This is a work in progress. The API is expected to change. 3 | 4 | # Universal Tool Server 5 | 6 | A dedicated tool server decouples the creation of specialized tools (e.g., for retrieving data from specific knowledge sources) from agent development. This separation enables different teams to contribute and manage tools independently. Agents can then be rapidly configured—by simply specifying a prompt and a set of accessible tools. This streamlined approach simplifies authentication and authorization and accelerates the deployment of agents into production. 7 | 8 | Users working in a local environment that need MCP, [can enable MCP support](#MCP-SSE). In comparison to [MCP](https://modelcontextprotocol.io/introduction), this specification uses stateless connection which makes it suitable for web deployment. 9 | 10 | ## Why 11 | 12 | - 🌐 **Stateless Web Deployment**: Deploy as a web server without the need for persistent connections, allowing easy autoscaling and load balancing. 13 | - 📡 **Simple REST Protocol**: Leverage a straightforward REST API. 14 | - 🔐 **Built-In Authentication**: Out-of-the-box auth support, ensuring only authorized users can access tools. 15 | - 🛠️ **Decoupled Tool Creation**: In an enterprise setting, decouple the creation of specialized tools (like data retrieval from specific knowledge sources) from the agent configuration. 16 | - ⚙️ **Works with LangChain tools**: You can integrate existing LangChain tools with minimal effort. 17 | 18 | ## Installation 19 | 20 | ```bash 21 | pip install universal-tool-server open-tool-client 22 | ``` 23 | 24 | ## Example Usage 25 | 26 | ### Server 27 | 28 | Add a server.py file to your project and define your tools with type hints. 29 | 30 | ```python 31 | from typing import Annotated 32 | from starlette.requests import Request 33 | 34 | from universal_tool_server.tools import InjectedRequest 35 | from universal_tool_server import Server, Auth 36 | 37 | app = Server() 38 | auth = Auth() 39 | app.add_auth(auth) 40 | 41 | 42 | @auth.authenticate 43 | async def authenticate(authorization: str) -> dict: 44 | """Authenticate incoming requests.""" 45 | api_key = authorization 46 | 47 | # Replace this with actual authentication logic. 48 | api_key_to_user = { 49 | "1": {"permissions": ["authenticated", "group1"], "identity": "some-user"}, 50 | "2": {"permissions": ["authenticated", "group2"], "identity": "another-user"}, 51 | } 52 | # This is just an example. You should replace this with an actual 53 | # implementation. 54 | if not api_key or api_key not in api_key_to_user: 55 | raise auth.exceptions.HTTPException(detail="Not authorized") 56 | return api_key_to_user[api_key] 57 | 58 | 59 | # Define tools 60 | 61 | @app.add_tool(permissions=["group1"]) 62 | async def echo(msg: str) -> str: 63 | """Echo a message.""" 64 | return msg + "!" 65 | 66 | 67 | # Tool that has access to the request object 68 | @app.add_tool(permissions=["authenticated"]) 69 | async def who_am_i(request: Annotated[Request, InjectedRequest]) -> str: 70 | """Get the user identity.""" 71 | return request.user.identity 72 | 73 | 74 | # You can also expose existing LangChain tools! 75 | from langchain_core.tools import tool 76 | 77 | 78 | @tool() 79 | async def say_hello() -> str: 80 | """Say hello.""" 81 | return "Hello" 82 | 83 | 84 | # Add an existing LangChain tool to the server with permissions! 85 | app.add_tool(say_hello, permissions=["group2"]) 86 | ``` 87 | 88 | ### Client 89 | 90 | Add a client.py file to your project and define your client. 91 | 92 | ```python 93 | import asyncio 94 | 95 | from universal_tool_client import get_async_client 96 | 97 | 98 | async def main(): 99 | if len(sys.argv) < 2: 100 | print( 101 | "Usage: uv run client.py url of universal-tool-server (i.e. http://localhost:8080/)>" 102 | ) 103 | sys.exit(1) 104 | 105 | url = sys.argv[1] 106 | client = get_async_client(url=url) 107 | # Check server status 108 | print(await client.ok()) # "OK" 109 | print(await client.info()) # Server version and other information 110 | 111 | # List tools 112 | print(await client.tools.list()) # List of tools 113 | # Call a tool 114 | print(await client.tools.call("add", {"x": 1, "y": 2})) # 3 115 | 116 | # Get as langchain tools 117 | select_tools = ["echo", "add"] 118 | tools = await client.tools.as_langchain_tools(select_tools) 119 | # Async 120 | print(await tools[0].ainvoke({"msg": "Hello"})) # "Hello!" 121 | print(await tools[1].ainvoke({"x": 1, "y": 3})) # 4 122 | 123 | 124 | if __name__ == "__main__": 125 | import sys 126 | 127 | asyncio.run(main()) 128 | ``` 129 | 130 | ### Sync Client 131 | 132 | If you need a synchronous client, you can use the `get_sync_client` function. 133 | 134 | ```python 135 | from universal_tool_client import get_sync_client 136 | ``` 137 | 138 | 139 | ### Using Existing LangChain Tools 140 | 141 | If you have existing LangChain tools, you can expose them via the API by using the `Server.tool` 142 | method which will add the tool to the server. 143 | 144 | This also gives you the option to add Authentication to an existing LangChain tool. 145 | 146 | ```python 147 | from open_tool_server import Server 148 | from langchain_core.tools import tool 149 | 150 | app = Server() 151 | 152 | # Say you have some existing langchain tool 153 | @tool() 154 | async def say_hello() -> str: 155 | """Say hello.""" 156 | return "Hello" 157 | 158 | # This is how you expose it via the API 159 | app.tool( 160 | say_hello, 161 | # You can include permissions if you're setting up Auth 162 | permissions=["group2"] 163 | ) 164 | ``` 165 | 166 | 167 | ### React Agent 168 | 169 | Here's an example of how you can use the Open Tool Server with a prebuilt LangGraph react agent. 170 | 171 | ```shell 172 | pip install langchain-anthropic langgraph 173 | ``` 174 | 175 | ```python 176 | import os 177 | 178 | from langchain_anthropic import ChatAnthropic 179 | from langgraph.prebuilt import create_react_agent 180 | 181 | from universal_tool_client import get_sync_client 182 | 183 | if "ANTHROPIC_API_KEY" not in os.environ: 184 | raise ValueError("Please set ANTHROPIC_API_KEY in the environment.") 185 | 186 | tool_server = get_sync_client( 187 | url=... # URL of the tool server 188 | # headers=... # If you enabled auth 189 | ) 190 | # Get tool definitions from the server 191 | tools = tool_server.tools.as_langchain_tools() 192 | print("Loaded tools:", tools) 193 | 194 | model = ChatAnthropic(model="claude-3-5-sonnet-20240620") 195 | agent = create_react_agent(model, tools=tools) 196 | print() 197 | 198 | user_message = "What is the temperature in Paris?" 199 | messages = agent.invoke({"messages": [{"role": "user", "content": user_message}]})[ 200 | "messages" 201 | ] 202 | 203 | for message in messages: 204 | message.pretty_print() 205 | ``` 206 | 207 | ### MCP SSE 208 | 209 | You can enable support for the MCP SSE protocol by passing `enable_mcp=True` to the Server constructor. 210 | 211 | > [!IMPORTANT] 212 | > Auth is not supported when using MCP SSE. So if you try to use auth and enable MCP, the server will raise an exception by design. 213 | 214 | ```python 215 | from universal_tool_server import Server 216 | 217 | app = Server(enable_mcp=True) 218 | 219 | 220 | @app.add_tool() 221 | async def echo(msg: str) -> str: 222 | """Echo a message.""" 223 | return msg + "!" 224 | ``` 225 | 226 | This will mount an MCP SSE app at /mcp/sse. You can use the MCP client to connect to the server. 227 | 228 | Use MCP client to connect to the server. **The url should be the same as the server url with `/mcp/sse` appended.** 229 | 230 | ```python 231 | from mcp import ClientSession 232 | 233 | from mcp.client.sse import sse_client 234 | 235 | async def main() -> None: 236 | # Please replace [host] with the actual host 237 | # IMPORTANT: Add /mcp/sse to the url! 238 | url = "[host]/mcp/sse" 239 | async with sse_client(url=url) as streams: 240 | async with ClientSession(streams[0], streams[1]) as session: 241 | await session.initialize() 242 | tools = await session.list_tools() 243 | print(tools) 244 | result = await session.call_tool("echo", {"msg": "Hello, world!"}) 245 | print(result) 246 | ``` 247 | 248 | ## Concepts 249 | 250 | ### Tool Definition 251 | 252 | A tool is a function that can be called by the client. It can be a simple function or a coroutine. The function signature should have type hints. The server will use these type hints to validate the input and output of the tool. 253 | 254 | ```python 255 | @app.add_tool() 256 | async def add(x: int, y: int) -> int: 257 | """Add two numbers.""" 258 | return x + y 259 | ``` 260 | 261 | #### Permissions 262 | 263 | You can specify `permissions` for a tool. The client must have the required permissions to call the tool. If the client does not have the required permissions, the server will return a 403 Forbidden error. 264 | 265 | ```python 266 | @app.add_tool(permissions=["group1"]) 267 | async def add(x: int, y: int) -> int: 268 | """Add two numbers.""" 269 | return x + y 270 | ``` 271 | 272 | A client must have **all** the required permissions to call the tool rather than a subset of the permissions. 273 | 274 | #### Injected Request 275 | 276 | A tool can request access to Starlette's `Request` object by using the `InjectedRequest` type hint. This can be useful for getting information about the request, such as the user's identity. 277 | 278 | ```python 279 | from typing import Annotated 280 | from universal_tool_server import InjectedRequest 281 | from starlette.requests import Request 282 | 283 | 284 | @app.add_tool(permissions=["group1"]) 285 | async def who_am_i(request: Annotated[Request, InjectedRequest]) -> str: 286 | """Return the user's identity""" 287 | # The `user` attribute can be used to retrieve the user object. 288 | # This object corresponds to the return value of the authentication function. 289 | return request.user.identity 290 | ``` 291 | 292 | 293 | ### Tool Discovery 294 | 295 | A client can list all available tools by calling the `tools.list` method. The server will return a list of tools with their names and descriptions. 296 | 297 | The client will only see tools for which they have the required permissions. 298 | 299 | ```python 300 | from universal_tool_client import get_async_client 301 | 302 | async def get_tools(): 303 | # Headers are entirely dependent on how you implement your authentication 304 | # (see Auth section) 305 | client = get_async_client(url="http://localhost:8080/", headers={"authorization": "api key"}) 306 | tools = await client.tools.list() 307 | # If you need langchain tools you can use the as_langchain_tools method 308 | langchain_tools = await client.tools.as_langchain_tools() 309 | # Do something 310 | ... 311 | ``` 312 | 313 | ### Auth 314 | 315 | You can add authentication to the server by defining an authentication function. 316 | 317 | **Tutorial** 318 | 319 | If you want to add realistic authentication to your server, you can follow the 3rd tutorial in the [Connecting an Authentication Provider](https://langchain-ai.github.io/langgraph/tutorials/auth/add_auth_server/) series for 320 | LangGraph Platform. It's a separate project, but the tutorial has useful information for setting up authentication in your server. 321 | 322 | #### Auth.authenticate 323 | 324 | The authentication function is a coroutine that can request any of the following parameters: 325 | 326 | | Parameter | Description | 327 | |-----------------|-----------------------------------------------------------------------------------------------------------------------------------| 328 | | `request` | The HTTP request object that encapsulates all details of the incoming client request, including metadata and routing info. | 329 | | `authorization` | A token or set of credentials used to authenticate the requestor and ensure secure access to the API or resource. | 330 | | `headers` | A dictionary of HTTP headers providing essential metadata (e.g., content type, encoding, user-agent) associated with the request. | 331 | | `body` | The payload of the request containing the data sent by the client, which may be formatted as JSON, XML, or form data. | 332 | 333 | The function should either: 334 | 335 | 1. Return a user object if the request is authenticated. 336 | 2. Raise an `auth.exceptions.HTTPException` if the request cannot be authenticated. 337 | 338 | ```python 339 | from universal_tool_server import Auth 340 | 341 | auth = Auth() 342 | 343 | @auth.authenticate 344 | async def authenticate(headers: dict[bytes, bytes]) -> dict: 345 | """Authenticate incoming requests.""" 346 | is_authenticated = ... # Your authentication logic here 347 | if not is_authenticated: 348 | raise auth.exceptions.HTTPException(detail="Not authorized") 349 | 350 | return { 351 | "identity": "some-user", 352 | "permissions": ["authenticated", "group1"], 353 | # Add any other user information here 354 | "foo": "bar", 355 | } 356 | ``` 357 | 358 | 359 | ## Awesome Servers 360 | 361 | * LangChain's [example tool server](https://github.com/langchain-ai/example-tool-server) with example tool to access github, hackernews, reddit. 362 | 363 | 364 | Would like to contribute your server to this list? Open a PR! 365 | -------------------------------------------------------------------------------- /libs/server/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/langchain-ai/universal-tool-server/9f9b17a3e3e1f77eb58b3d3455ba4ba9c22084a2/libs/server/__init__.py -------------------------------------------------------------------------------- /libs/server/pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "universal-tool-server" 3 | version = "0.0.3" 4 | description = "Universal Tool Server (Python)" 5 | readme = "README.md" 6 | requires-python = ">=3.10" 7 | dependencies = [ 8 | "fastapi>=0.110.0", 9 | "jsonschema-rs>=0.20.1", 10 | "langchain-core>=0.2.0", 11 | "mcp>=1.3.0", 12 | "orjson>=3.10.15", 13 | "pydantic>=2.7.2", 14 | "structlog>=20.1.0", 15 | ] 16 | 17 | [dependency-groups] 18 | test = [ 19 | "uvicorn>=0.34.0", 20 | "httpx>=0.28.1", 21 | "pytest>=8.3.4", 22 | "pytest-asyncio>=0.25.3", 23 | "pytest-cov>=6.0.0", 24 | "pytest-mock>=3.14.0", 25 | "pytest-socket>=0.7.0", 26 | "pytest-timeout>=2.3.1", 27 | "ruff>=0.9.7", 28 | "universal-tool-client", 29 | ] 30 | 31 | [project.urls] 32 | repository = "https://github.com/langchain-ai/universal-tool-server" 33 | 34 | 35 | [tool.pytest.ini_options] 36 | minversion = "8.0" 37 | # -ra: Report all extra test outcomes (passed, skipped, failed, etc.) 38 | # -q: Enable quiet mode for less cluttered output 39 | # -v: Enable verbose output to display detailed test names and statuses 40 | # --durations=5: Show the 10 slowest tests after the run (useful for performance tuning) 41 | addopts = "-ra -q -v --durations=5" 42 | testpaths = [ 43 | "tests", 44 | ] 45 | python_files = ["test_*.py"] 46 | python_functions = ["test_*"] 47 | asyncio_mode = "auto" 48 | asyncio_default_fixture_loop_scope = "function" 49 | 50 | 51 | [tool.ruff] 52 | line-length = 88 53 | target-version = "py310" 54 | 55 | [tool.ruff.lint] 56 | select = [ 57 | "E", # pycodestyle errors 58 | "W", # pycodestyle warnings 59 | "F", # pyflakes 60 | "I", # isort 61 | "B", # flake8-bugbear 62 | ] 63 | ignore = [ 64 | "E501" # line-length 65 | ] 66 | 67 | 68 | [tool.mypy] 69 | python_version = "3.11" 70 | warn_return_any = true 71 | warn_unused_configs = true 72 | disallow_untyped_defs = true 73 | check_untyped_defs = true 74 | 75 | [tool.uv.sources] 76 | universal-tool-client = { path = "../sdk-py" } 77 | 78 | [build-system] 79 | requires = ["hatchling"] 80 | build-backend = "hatchling.build" 81 | 82 | -------------------------------------------------------------------------------- /libs/server/run_integration.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/bash 2 | uv run uvicorn tests.integration.mcp_server:app --host localhost --port 8131 --reload & 3 | pid=$! 4 | trap "kill $pid" EXIT 5 | sleep 5 # wait a bit for the server to be fully up 6 | uv run pytest tests/integration 7 | 8 | -------------------------------------------------------------------------------- /libs/server/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/langchain-ai/universal-tool-server/9f9b17a3e3e1f77eb58b3d3455ba4ba9c22084a2/libs/server/tests/__init__.py -------------------------------------------------------------------------------- /libs/server/tests/integration/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/langchain-ai/universal-tool-server/9f9b17a3e3e1f77eb58b3d3455ba4ba9c22084a2/libs/server/tests/integration/__init__.py -------------------------------------------------------------------------------- /libs/server/tests/integration/mcp_server.py: -------------------------------------------------------------------------------- 1 | from typing import Annotated 2 | 3 | from starlette.requests import Request 4 | 5 | from universal_tool_server import Server 6 | from universal_tool_server.tools import InjectedRequest 7 | 8 | app = Server(enable_mcp=True) 9 | 10 | 11 | @app.add_tool() 12 | async def echo(msg: str) -> str: 13 | """Echo a message.""" 14 | return msg + "!" 15 | 16 | 17 | @app.add_tool 18 | async def add(x: int, y: int) -> int: 19 | """Add two numbers.""" 20 | return x + y 21 | 22 | 23 | @app.add_tool() 24 | async def say_hello() -> str: 25 | """Say hello.""" 26 | return "Hello" 27 | 28 | 29 | @app.add_tool() 30 | async def unavailable_tool(request: Annotated[Request, InjectedRequest]) -> str: 31 | """Tool not show up with MCP due to the injected request not being available.""" 32 | return "Hello" 33 | -------------------------------------------------------------------------------- /libs/server/tests/integration/test_client.py: -------------------------------------------------------------------------------- 1 | """Integration test to test the MCP server.""" 2 | 3 | import os 4 | from contextlib import asynccontextmanager 5 | from typing import AsyncGenerator 6 | 7 | from mcp import ClientSession 8 | from mcp.client.sse import sse_client 9 | 10 | 11 | @asynccontextmanager 12 | async def get_client( 13 | url: str, *, headers: dict | None = None 14 | ) -> AsyncGenerator[ClientSession, None]: 15 | async with sse_client(url=url, headers=headers) as streams: 16 | async with ClientSession(streams[0], streams[1]) as session: 17 | await session.initialize() 18 | yield session 19 | 20 | 21 | URL = os.environ.get("MCP_SSE_URL", "http://localhost:8131/mcp/sse") 22 | 23 | 24 | async def test_list_tools() -> None: 25 | async with get_client(URL) as session: 26 | list_tool_result = await session.list_tools() 27 | tools = [ 28 | { 29 | "name": tool.name, 30 | "description": tool.description, 31 | "inputSchema": tool.inputSchema, 32 | } 33 | for tool in list_tool_result.tools 34 | ] 35 | assert tools == [ 36 | { 37 | "name": "echo", 38 | "description": "Echo a message.", 39 | "inputSchema": { 40 | "type": "object", 41 | "properties": {"msg": {"type": "string"}}, 42 | "required": ["msg"], 43 | }, 44 | }, 45 | { 46 | "name": "add", 47 | "description": "Add two numbers.", 48 | "inputSchema": { 49 | "type": "object", 50 | "properties": {"x": {"type": "integer"}, "y": {"type": "integer"}}, 51 | "required": ["x", "y"], 52 | }, 53 | }, 54 | { 55 | "name": "say_hello", 56 | "description": "Say hello.", 57 | "inputSchema": {"type": "object", "properties": {}}, 58 | }, 59 | ] 60 | 61 | 62 | async def test_call_tool() -> None: 63 | """Test calling a tool.""" 64 | async with get_client(URL) as session: 65 | tool_call = await session.call_tool("echo", {"msg": "Hello"}) 66 | assert tool_call.content[0].text == "Hello!" 67 | -------------------------------------------------------------------------------- /libs/server/tests/unit_tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/langchain-ai/universal-tool-server/9f9b17a3e3e1f77eb58b3d3455ba4ba9c22084a2/libs/server/tests/unit_tests/__init__.py -------------------------------------------------------------------------------- /libs/server/tests/unit_tests/test_tools.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from langchain_core.tools import tool 4 | from typing_extensions import TypedDict 5 | 6 | from universal_tool_server.tools import get_output_schema 7 | 8 | 9 | async def test_get_output_schema() -> None: 10 | """Test get output schema.""" 11 | 12 | @tool 13 | def another_tool() -> str: 14 | """Hello""" 15 | 16 | assert get_output_schema(another_tool) == {"type": "string"} 17 | 18 | @tool 19 | async def async_another_tool() -> str: 20 | """Hello""" 21 | 22 | assert get_output_schema(async_another_tool) == {"type": "string"} 23 | 24 | class Foo(TypedDict): 25 | bar: str 26 | 27 | @tool 28 | def call_tool() -> Foo: 29 | """Hello""" 30 | pass 31 | 32 | assert get_output_schema(call_tool) == { 33 | "properties": {"bar": {"title": "Bar", "type": "string"}}, 34 | "required": ["bar"], 35 | "title": "Foo", 36 | "type": "object", 37 | } 38 | 39 | @tool 40 | def void_tool() -> None: 41 | """Hello""" 42 | pass 43 | 44 | assert get_output_schema(void_tool) == {"type": "null"} 45 | 46 | @tool 47 | def any_tool() -> Any: 48 | """Hello""" 49 | pass 50 | 51 | assert get_output_schema(any_tool) == {} 52 | 53 | # Unspecified return type (same as Any) 54 | @tool 55 | def unspecified_tool(): 56 | """Hello""" 57 | pass 58 | 59 | assert get_output_schema(unspecified_tool) == {} 60 | -------------------------------------------------------------------------------- /libs/server/tests/unit_tests/test_with_sdk.py: -------------------------------------------------------------------------------- 1 | """Test the server.""" 2 | 3 | from contextlib import asynccontextmanager 4 | from typing import Annotated, AsyncGenerator, Optional, cast 5 | 6 | import pytest 7 | from fastapi import FastAPI 8 | from httpx import ASGITransport, HTTPStatusError 9 | from starlette.authentication import BaseUser 10 | from starlette.requests import Request 11 | from universal_tool_client import AsyncClient, get_async_client 12 | 13 | from universal_tool_server import Server 14 | from universal_tool_server._version import __version__ 15 | from universal_tool_server.auth import Auth 16 | from universal_tool_server.tools import InjectedRequest 17 | 18 | from ..unit_tests.utils import AnyStr 19 | 20 | 21 | @asynccontextmanager 22 | async def get_async_test_client( 23 | server: FastAPI, 24 | *, 25 | path: Optional[str] = None, 26 | raise_app_exceptions: bool = True, 27 | headers: dict | None = None, 28 | ) -> AsyncGenerator[AsyncClient, None]: 29 | """Get an async client.""" 30 | url = "http://localhost:9999" 31 | if path: 32 | url += path 33 | transport = ASGITransport( 34 | app=server, 35 | raise_app_exceptions=raise_app_exceptions, 36 | ) 37 | 38 | client = get_async_client(transport=transport, headers=headers) 39 | 40 | try: 41 | yield cast(AsyncClient, client) 42 | finally: 43 | del client 44 | 45 | 46 | async def test_health() -> None: 47 | app = Server() 48 | async with get_async_test_client(app) as client: 49 | assert await client.health() == {"status": "OK"} 50 | 51 | 52 | async def test_info() -> None: 53 | app = Server() 54 | async with get_async_test_client(app) as client: 55 | assert await client.info() == { 56 | "version": __version__, 57 | } 58 | 59 | 60 | async def test_add_langchain_tool() -> None: 61 | """Test adding a tool that's defined using langchain tool decorator.""" 62 | app = Server() 63 | 64 | # Test prior to adding any tools 65 | async with get_async_test_client(app) as client: 66 | tools = await client.tools.list() 67 | assert tools == [] 68 | 69 | @app.add_tool 70 | async def say_hello() -> str: 71 | """Say hello.""" 72 | return "Hello" 73 | 74 | @app.add_tool 75 | async def echo(msg: str) -> str: 76 | """Echo the message back.""" 77 | return msg 78 | 79 | @app.add_tool 80 | async def add(x: int, y: int) -> int: 81 | """Add two integers.""" 82 | return x + y 83 | 84 | async with get_async_test_client(app) as client: 85 | data = await client.tools.list() 86 | assert data == [ 87 | { 88 | "description": "Say hello.", 89 | "id": "say_hello@1.0.0", 90 | "input_schema": {"properties": {}, "type": "object"}, 91 | "name": "say_hello", 92 | "output_schema": {"type": "string"}, 93 | "version": "1.0.0", 94 | }, 95 | { 96 | "description": "Echo the message back.", 97 | "id": "echo@1.0.0", 98 | "input_schema": { 99 | "properties": {"msg": {"type": "string"}}, 100 | "required": ["msg"], 101 | "type": "object", 102 | }, 103 | "name": "echo", 104 | "output_schema": {"type": "string"}, 105 | "version": "1.0.0", 106 | }, 107 | { 108 | "description": "Add two integers.", 109 | "id": "add@1.0.0", 110 | "input_schema": { 111 | "properties": {"x": {"type": "integer"}, "y": {"type": "integer"}}, 112 | "required": ["x", "y"], 113 | "type": "object", 114 | }, 115 | "name": "add", 116 | "output_schema": {"type": "integer"}, 117 | "version": "1.0.0", 118 | }, 119 | ] 120 | 121 | 122 | async def test_call_tool() -> None: 123 | """Test call parameterless tool.""" 124 | app = Server() 125 | 126 | @app.add_tool 127 | async def say_hello() -> str: 128 | """Say hello.""" 129 | return "Hello" 130 | 131 | @app.add_tool 132 | async def add(x: int, y: int) -> int: 133 | """Add two integers.""" 134 | return x + y 135 | 136 | async with get_async_test_client(app) as client: 137 | response = await client.tools.call( 138 | "say_hello", 139 | {}, 140 | ) 141 | 142 | assert response == { 143 | "call_id": AnyStr(), 144 | "value": "Hello", 145 | "success": True, 146 | } 147 | 148 | 149 | async def test_create_langchain_tools_from_server() -> None: 150 | """Test create langchain tools from server.""" 151 | app = Server() 152 | 153 | @app.add_tool 154 | async def say_hello() -> str: 155 | """Say hello.""" 156 | return "Hello" 157 | 158 | @app.add_tool 159 | async def add(x: int, y: int) -> int: 160 | """Add two integers.""" 161 | return x + y 162 | 163 | async with get_async_test_client(app) as client: 164 | tools = await client.tools.as_langchain_tools(tool_ids=["say_hello", "add"]) 165 | say_hello_client_side = tools[0] 166 | add_client_side = tools[1] 167 | 168 | assert await say_hello_client_side.ainvoke({}) == "Hello" 169 | assert say_hello_client_side.args_schema == {"properties": {}, "type": "object"} 170 | 171 | assert await add_client_side.ainvoke({"x": 1, "y": 2}) == 3 172 | assert add_client_side.args == { 173 | "x": {"type": "integer"}, 174 | "y": {"type": "integer"}, 175 | } 176 | 177 | 178 | class User(BaseUser): 179 | """User class.""" 180 | 181 | @property 182 | def is_authenticated(self) -> bool: 183 | """Check if user is authenticated.""" 184 | return True 185 | 186 | @property 187 | def display_name(self) -> str: 188 | """Get user's display name.""" 189 | return "Test User" 190 | 191 | @property 192 | def identity(self) -> str: 193 | """Get user's identity.""" 194 | return "test-user" 195 | 196 | 197 | async def test_auth_list_tools() -> None: 198 | """Test ability to list tools.""" 199 | 200 | app = Server() 201 | auth = Auth() 202 | app.add_auth(auth) 203 | 204 | @app.add_tool(permissions=["group1"]) 205 | async def say_hello() -> str: 206 | """Say hello.""" 207 | return "Hello" 208 | 209 | @app.add_tool(permissions=["group2"]) 210 | async def add(x: int, y: int) -> int: 211 | """Add two integers.""" 212 | return x + y 213 | 214 | @auth.authenticate 215 | async def authenticate(headers: dict[bytes, bytes]) -> dict: 216 | """Authenticate incoming requests.""" 217 | # Validate credentials (e.g., API key, JWT token) 218 | api_key = headers.get(b"x-api-key") 219 | if not api_key or api_key != b"123": 220 | raise auth.exceptions.HTTPException(detail="Not authorized") 221 | 222 | return {"permissions": ["group1"], "identity": "some-user"} 223 | 224 | async with get_async_test_client(app, headers={"x-api-key": "123"}) as client: 225 | tools = await client.tools.list() 226 | assert tools == [ 227 | { 228 | "description": "Say hello.", 229 | "id": "say_hello@1.0.0", 230 | "input_schema": {"properties": {}, "type": "object"}, 231 | "name": "say_hello", 232 | "output_schema": {"type": "string"}, 233 | "version": "1.0.0", 234 | } 235 | ] 236 | 237 | await client.tools.call("say_hello", {}) 238 | 239 | 240 | async def test_call_tool_with_auth() -> None: 241 | """Test calling a tool with authentication provided.""" 242 | app = Server() 243 | 244 | @app.add_tool(permissions=["group1"]) 245 | async def say_hello(request: Annotated[Request, InjectedRequest]) -> str: 246 | """Say hello.""" 247 | return "Hello" 248 | 249 | auth = Auth() 250 | 251 | @auth.authenticate 252 | async def authenticate(headers: dict[bytes, bytes]) -> dict: 253 | """Authenticate incoming requests.""" 254 | api_key = headers.get(b"x-api-key") 255 | 256 | api_key_to_user = { 257 | b"1": {"permissions": ["group1"], "identity": "some-user"}, 258 | b"2": {"permissions": ["group2"], "identity": "another-user"}, 259 | } 260 | 261 | if not api_key or api_key not in api_key_to_user: 262 | raise auth.exceptions.HTTPException(detail="Not authorized") 263 | 264 | return api_key_to_user[api_key] 265 | 266 | app.add_auth(auth) 267 | 268 | async with get_async_test_client(app, headers={"x-api-key": "1"}) as client: 269 | assert await client.tools.call("say_hello", {}) == { 270 | "call_id": AnyStr(), 271 | "value": "Hello", 272 | "success": True, 273 | } 274 | async with get_async_test_client(app, headers={"x-api-key": "2"}) as client: 275 | # `2` does not have permission to call `say_hello` 276 | with pytest.raises(HTTPStatusError) as exception_info: 277 | assert await client.tools.call("say_hello", {}) 278 | assert exception_info.value.response.status_code == 403 279 | 280 | async with get_async_test_client(app, headers={"x-api-key": "3"}) as client: 281 | # `3` does not have permission to call `say_hello` 282 | with pytest.raises(HTTPStatusError) as exception_info: 283 | assert await client.tools.call("say_hello", {}) 284 | assert exception_info.value.response.status_code == 401 285 | 286 | 287 | async def test_call_tool_with_injected() -> None: 288 | """Test calling a tool with an injected request.""" 289 | app = Server() 290 | 291 | @app.add_tool(permissions=["authorized"]) 292 | async def get_user_identity(request: Annotated[Request, InjectedRequest]) -> str: 293 | """Get the user's identity.""" 294 | return request.user.identity 295 | 296 | auth = Auth() 297 | 298 | @auth.authenticate 299 | async def authenticate(headers: dict[bytes, bytes]) -> dict: 300 | """Authenticate incoming requests.""" 301 | # Validate credentials (e.g., API key, JWT token) 302 | api_key = headers.get(b"x-api-key") 303 | 304 | api_key_to_user = { 305 | b"1": {"permissions": ["authorized"], "identity": "some-user"}, 306 | b"2": {"permissions": ["authorized"], "identity": "another-user"}, 307 | b"3": {"permissions": ["not-authorized"], "identity": "not-authorized"}, 308 | } 309 | 310 | if not api_key or api_key not in api_key_to_user: 311 | raise auth.exceptions.HTTPException(detail="Not authorized") 312 | 313 | return api_key_to_user[api_key] 314 | 315 | app.add_auth(auth) 316 | 317 | async with get_async_test_client(app, headers={"x-api-key": "1"}) as client: 318 | result = await client.tools.call("get_user_identity") 319 | assert result["value"] == "some-user" 320 | 321 | async with get_async_test_client(app, headers={"x-api-key": "2"}) as client: 322 | result = await client.tools.call("get_user_identity") 323 | assert result["value"] == "another-user" 324 | 325 | async with get_async_test_client(app, headers={"x-api-key": "3"}) as client: 326 | with pytest.raises(HTTPStatusError) as exception_info: 327 | await client.tools.call("get_user_identity", {}) 328 | assert exception_info.value.response.status_code == 403 329 | 330 | # Authenticated but tool does not exist 331 | async with get_async_test_client(app, headers={"x-api-key": "1"}) as client: 332 | with pytest.raises(HTTPStatusError) as exception_info: 333 | await client.tools.call("does_not_exist", {}) 334 | assert exception_info.value.response.status_code == 403 335 | 336 | # Not authenticated 337 | async with get_async_test_client(app, headers={"x-api-key": "6"}) as client: 338 | # Make sure this raises 401? 339 | with pytest.raises(HTTPStatusError) as exception_info: 340 | await client.tools.call("does_not_exist", {}) 341 | 342 | assert exception_info.value.response.status_code == 401 343 | 344 | 345 | async def test_exposing_existing_langchain_tools() -> None: 346 | """Test exposing existing langchain tools.""" 347 | from langchain_core.tools import StructuredTool, tool 348 | 349 | @tool 350 | def say_hello_sync() -> str: 351 | """Say hello.""" 352 | return "Hello" 353 | 354 | @tool 355 | async def say_hello_async() -> str: 356 | """Say hello.""" 357 | return "Hello" 358 | 359 | def multiply(a: int, b: int) -> int: 360 | """Multiply two numbers.""" 361 | return a * b 362 | 363 | async def amultiply(a: int, b: int) -> int: 364 | """Multiply two numbers.""" 365 | return a * b 366 | 367 | calculator = StructuredTool.from_function(func=multiply, coroutine=amultiply) 368 | 369 | server = Server() 370 | auth = Auth() 371 | server.add_auth(auth) 372 | 373 | @auth.authenticate 374 | async def authenticate(headers: dict) -> dict: 375 | """Authenticate incoming requests.""" 376 | api_key = headers.get(b"x-api-key") 377 | 378 | api_key_to_user = { 379 | b"1": {"permissions": ["group1"], "identity": "some-user"}, 380 | } 381 | 382 | if not api_key or api_key not in api_key_to_user: 383 | raise auth.exceptions.HTTPException(detail="Not authorized") 384 | 385 | return api_key_to_user[api_key] 386 | 387 | server.add_tool(say_hello_sync, permissions=["group1"]) 388 | server.add_tool(say_hello_async, permissions=["group1"]) 389 | server.add_tool(calculator, permissions=["group1"]) 390 | 391 | async with get_async_test_client(server, headers={"x-api-key": "1"}) as client: 392 | tools = await client.tools.list() 393 | assert tools == [ 394 | { 395 | "description": "Say hello.", 396 | "id": "say_hello_sync@1.0.0", 397 | "input_schema": {"properties": {}, "type": "object"}, 398 | "name": "say_hello_sync", 399 | "output_schema": {"type": "string"}, 400 | "version": "1.0.0", 401 | }, 402 | { 403 | "description": "Say hello.", 404 | "id": "say_hello_async@1.0.0", 405 | "input_schema": {"properties": {}, "type": "object"}, 406 | "name": "say_hello_async", 407 | "output_schema": {"type": "string"}, 408 | "version": "1.0.0", 409 | }, 410 | { 411 | "description": "Multiply two numbers.", 412 | "id": "multiply@1.0.0", 413 | "input_schema": { 414 | "properties": {"a": {"type": "integer"}, "b": {"type": "integer"}}, 415 | "required": ["a", "b"], 416 | "type": "object", 417 | }, 418 | "name": "multiply", 419 | "output_schema": {"type": "integer"}, 420 | "version": "1.0.0", 421 | }, 422 | ] 423 | 424 | result = await client.tools.call("say_hello_sync", {}) 425 | assert result == { 426 | "call_id": AnyStr(), 427 | "value": "Hello", 428 | "success": True, 429 | } 430 | 431 | result = await client.tools.call("say_hello_async", {}) 432 | assert result == { 433 | "call_id": AnyStr(), 434 | "value": "Hello", 435 | "success": True, 436 | } 437 | 438 | result = await client.tools.call("multiply", {"a": 2, "b": 3}) 439 | assert result == { 440 | "call_id": AnyStr(), 441 | "value": 6, 442 | "success": True, 443 | } 444 | 445 | 446 | async def test_call_tool_by_version() -> None: 447 | """Test calling a tool by version.""" 448 | app = Server() 449 | 450 | @app.add_tool(version=1) 451 | async def say_hello() -> str: 452 | """Say hello.""" 453 | return "v1" 454 | 455 | @app.add_tool(version="2.0.0") 456 | async def say_hello() -> str: # noqa: F811 457 | """Say hello.""" 458 | return "v2" 459 | 460 | async with get_async_test_client(app) as client: 461 | tools = await client.tools.list() 462 | assert tools == [ 463 | { 464 | "description": "Say hello.", 465 | "id": "say_hello@1.0.0", 466 | "input_schema": {"properties": {}, "type": "object"}, 467 | "name": "say_hello", 468 | "output_schema": {"type": "string"}, 469 | "version": "1.0.0", 470 | }, 471 | { 472 | "description": "Say hello.", 473 | "id": "say_hello@2.0.0", 474 | "input_schema": {"properties": {}, "type": "object"}, 475 | "name": "say_hello", 476 | "output_schema": {"type": "string"}, 477 | "version": "2.0.0", 478 | }, 479 | ] 480 | 481 | # call the tool by version 482 | result = await client.tools.call("say_hello@1", {}) 483 | assert result == { 484 | "call_id": AnyStr(), 485 | "value": "v1", 486 | "success": True, 487 | } 488 | 489 | result = await client.tools.call("say_hello@1.0.0", {}) 490 | assert result == { 491 | "call_id": AnyStr(), 492 | "value": "v1", 493 | "success": True, 494 | } 495 | 496 | result = await client.tools.call("say_hello@2", {}) 497 | assert result == { 498 | "call_id": AnyStr(), 499 | "value": "v2", 500 | "success": True, 501 | } 502 | 503 | result = await client.tools.call("say_hello@2.0", {}) 504 | assert result == { 505 | "call_id": AnyStr(), 506 | "value": "v2", 507 | "success": True, 508 | } 509 | 510 | result = await client.tools.call("say_hello", {}) 511 | assert result == { 512 | "call_id": AnyStr(), 513 | "value": "v2", 514 | "success": True, 515 | } 516 | -------------------------------------------------------------------------------- /libs/server/tests/unit_tests/test_with_sync_sdk.py: -------------------------------------------------------------------------------- 1 | """Test the server.""" 2 | 3 | from contextlib import contextmanager 4 | from typing import Annotated, Generator, Optional 5 | 6 | import pytest 7 | from fastapi import FastAPI 8 | from httpx import HTTPStatusError 9 | from starlette.authentication import BaseUser 10 | from starlette.requests import Request 11 | from universal_tool_client import SyncClient 12 | 13 | from universal_tool_server import Server 14 | from universal_tool_server._version import __version__ 15 | from universal_tool_server.auth import Auth 16 | from universal_tool_server.tools import InjectedRequest 17 | 18 | from ..unit_tests.utils import AnyStr 19 | 20 | 21 | @contextmanager 22 | def get_sync_test_client( 23 | server: FastAPI, 24 | *, 25 | path: Optional[str] = None, 26 | raise_app_exceptions: bool = True, 27 | headers: dict[str, str] | None = None, 28 | ) -> Generator[SyncClient, None, None]: 29 | """Get an async client.""" 30 | url = "http://localhost:9999" 31 | if path: 32 | url += path 33 | 34 | from starlette.testclient import TestClient 35 | 36 | client = TestClient( 37 | server, raise_server_exceptions=raise_app_exceptions, headers=headers 38 | ) 39 | 40 | try: 41 | yield SyncClient(client) 42 | finally: 43 | del client 44 | 45 | 46 | def test_health() -> None: 47 | app = Server() 48 | with get_sync_test_client(app) as client: 49 | assert client.health() == { 50 | "status": "OK", 51 | } 52 | 53 | 54 | def test_info() -> None: 55 | app = Server() 56 | with get_sync_test_client(app) as client: 57 | assert client.info() == { 58 | "version": __version__, 59 | } 60 | 61 | 62 | def test_add_langchain_tool() -> None: 63 | """Test adding a tool that's defined using langchain tool decorator.""" 64 | app = Server() 65 | 66 | # Test prior to adding any tools 67 | with get_sync_test_client(app) as client: 68 | tools = client.tools.list() 69 | assert tools == [] 70 | 71 | @app.add_tool 72 | def say_hello() -> str: 73 | """Say hello.""" 74 | return "Hello" 75 | 76 | @app.add_tool 77 | def echo(msg: str) -> str: 78 | """Echo the message back.""" 79 | return msg 80 | 81 | @app.add_tool 82 | def add(x: int, y: int) -> int: 83 | """Add two integers.""" 84 | return x + y 85 | 86 | with get_sync_test_client(app) as client: 87 | data = client.tools.list() 88 | assert data == [ 89 | { 90 | "description": "Say hello.", 91 | "id": "say_hello@1.0.0", 92 | "input_schema": {"properties": {}, "type": "object"}, 93 | "name": "say_hello", 94 | "output_schema": {"type": "string"}, 95 | "version": "1.0.0", 96 | }, 97 | { 98 | "description": "Echo the message back.", 99 | "id": "echo@1.0.0", 100 | "input_schema": { 101 | "properties": {"msg": {"type": "string"}}, 102 | "required": ["msg"], 103 | "type": "object", 104 | }, 105 | "name": "echo", 106 | "output_schema": {"type": "string"}, 107 | "version": "1.0.0", 108 | }, 109 | { 110 | "description": "Add two integers.", 111 | "id": "add@1.0.0", 112 | "input_schema": { 113 | "properties": {"x": {"type": "integer"}, "y": {"type": "integer"}}, 114 | "required": ["x", "y"], 115 | "type": "object", 116 | }, 117 | "name": "add", 118 | "output_schema": {"type": "integer"}, 119 | "version": "1.0.0", 120 | }, 121 | ] 122 | 123 | 124 | def test_call_tool() -> None: 125 | """Test call parameterless tool.""" 126 | app = Server() 127 | 128 | @app.add_tool 129 | def say_hello() -> str: 130 | """Say hello.""" 131 | return "Hello" 132 | 133 | @app.add_tool 134 | def add(x: int, y: int) -> int: 135 | """Add two integers.""" 136 | return x + y 137 | 138 | with get_sync_test_client(app) as client: 139 | response = client.tools.call( 140 | "say_hello", 141 | {}, 142 | ) 143 | 144 | assert response == { 145 | "call_id": AnyStr(), 146 | "value": "Hello", 147 | "success": True, 148 | } 149 | 150 | 151 | def test_create_langchain_tools_from_server() -> None: 152 | """Test create langchain tools from server.""" 153 | app = Server() 154 | 155 | @app.add_tool 156 | def say_hello() -> str: 157 | """Say hello.""" 158 | return "Hello" 159 | 160 | @app.add_tool 161 | def add(x: int, y: int) -> int: 162 | """Add two integers.""" 163 | return x + y 164 | 165 | with get_sync_test_client(app) as client: 166 | tools = client.tools.as_langchain_tools(tool_ids=["say_hello", "add"]) 167 | say_hello_client_side = tools[0] 168 | add_client_side = tools[1] 169 | 170 | assert say_hello_client_side.invoke({}) == "Hello" 171 | assert say_hello_client_side.args_schema == {"properties": {}, "type": "object"} 172 | 173 | assert add_client_side.invoke({"x": 1, "y": 2}) == 3 174 | assert add_client_side.args == { 175 | "x": {"type": "integer"}, 176 | "y": {"type": "integer"}, 177 | } 178 | 179 | 180 | class User(BaseUser): 181 | """User class.""" 182 | 183 | @property 184 | def is_authenticated(self) -> bool: 185 | """Check if user is authenticated.""" 186 | return True 187 | 188 | @property 189 | def display_name(self) -> str: 190 | """Get user's display name.""" 191 | return "Test User" 192 | 193 | @property 194 | def identity(self) -> str: 195 | """Get user's identity.""" 196 | return "test-user" 197 | 198 | 199 | def test_auth_list_tools() -> None: 200 | """Test ability to list tools.""" 201 | 202 | app = Server() 203 | auth = Auth() 204 | app.add_auth(auth) 205 | 206 | @app.add_tool(permissions=["group1"]) 207 | def say_hello() -> str: 208 | """Say hello.""" 209 | return "Hello" 210 | 211 | @app.add_tool(permissions=["group2"]) 212 | def add(x: int, y: int) -> int: 213 | """Add two integers.""" 214 | return x + y 215 | 216 | @auth.authenticate 217 | async def authenticate(headers: dict[bytes, bytes]) -> dict: 218 | """Authenticate incoming requests.""" 219 | # Validate credentials (e.g., API key, JWT token) 220 | api_key = headers.get(b"x-api-key") 221 | if not api_key or api_key != b"123": 222 | raise auth.exceptions.HTTPException(detail="Not authorized") 223 | 224 | return {"permissions": ["group1"], "identity": "some-user"} 225 | 226 | with get_sync_test_client(app, headers={"x-api-key": "123"}) as client: 227 | tools = client.tools.list() 228 | assert tools == [ 229 | { 230 | "description": "Say hello.", 231 | "id": "say_hello@1.0.0", 232 | "input_schema": {"properties": {}, "type": "object"}, 233 | "name": "say_hello", 234 | "output_schema": {"type": "string"}, 235 | "version": "1.0.0", 236 | } 237 | ] 238 | 239 | client.tools.call("say_hello", {}) 240 | 241 | 242 | async def test_call_tool_with_auth() -> None: 243 | """Test calling a tool with authentication provided.""" 244 | app = Server() 245 | 246 | @app.add_tool(permissions=["group1"]) 247 | def say_hello(request: Annotated[Request, InjectedRequest]) -> str: 248 | """Say hello.""" 249 | return "Hello" 250 | 251 | auth = Auth() 252 | 253 | @auth.authenticate 254 | async def authenticate(headers: dict[bytes, bytes]) -> dict: 255 | """Authenticate incoming requests.""" 256 | api_key = headers.get(b"x-api-key") 257 | 258 | api_key_to_user = { 259 | b"1": {"permissions": ["group1"], "identity": "some-user"}, 260 | b"2": {"permissions": ["group2"], "identity": "another-user"}, 261 | } 262 | 263 | if not api_key or api_key not in api_key_to_user: 264 | raise auth.exceptions.HTTPException(detail="Not authorized") 265 | 266 | return api_key_to_user[api_key] 267 | 268 | app.add_auth(auth) 269 | 270 | with get_sync_test_client(app, headers={"x-api-key": "1"}) as client: 271 | assert client.tools.call("say_hello", {}) == { 272 | "call_id": AnyStr(), 273 | "value": "Hello", 274 | "success": True, 275 | } 276 | with get_sync_test_client(app, headers={"x-api-key": "2"}) as client: 277 | # `2` does not have permission to call `say_hello` 278 | with pytest.raises(HTTPStatusError) as exception_info: 279 | assert client.tools.call("say_hello", {}) 280 | assert exception_info.value.response.status_code == 403 281 | 282 | with get_sync_test_client(app, headers={"x-api-key": "3"}) as client: 283 | # `3` does not have permission to call `say_hello` 284 | with pytest.raises(HTTPStatusError) as exception_info: 285 | assert client.tools.call("say_hello", {}) 286 | assert exception_info.value.response.status_code == 401 287 | 288 | 289 | async def test_call_tool_with_injected() -> None: 290 | """Test calling a tool with an injected request.""" 291 | app = Server() 292 | 293 | @app.add_tool(permissions=["authorized"]) 294 | def get_user_identity(request: Annotated[Request, InjectedRequest]) -> str: 295 | """Get the user's identity.""" 296 | return request.user.identity 297 | 298 | auth = Auth() 299 | 300 | @auth.authenticate 301 | async def authenticate(headers: dict[bytes, bytes]) -> dict: 302 | """Authenticate incoming requests.""" 303 | # Validate credentials (e.g., API key, JWT token) 304 | api_key = headers.get(b"x-api-key") 305 | 306 | api_key_to_user = { 307 | b"1": {"permissions": ["authorized"], "identity": "some-user"}, 308 | b"2": {"permissions": ["authorized"], "identity": "another-user"}, 309 | b"3": {"permissions": ["not-authorized"], "identity": "not-authorized"}, 310 | } 311 | 312 | if not api_key or api_key not in api_key_to_user: 313 | raise auth.exceptions.HTTPException(detail="Not authorized") 314 | 315 | return api_key_to_user[api_key] 316 | 317 | app.add_auth(auth) 318 | 319 | with get_sync_test_client(app, headers={"x-api-key": "1"}) as client: 320 | result = client.tools.call("get_user_identity") 321 | assert result["value"] == "some-user" 322 | 323 | with get_sync_test_client(app, headers={"x-api-key": "2"}) as client: 324 | result = client.tools.call("get_user_identity") 325 | assert result["value"] == "another-user" 326 | 327 | with get_sync_test_client(app, headers={"x-api-key": "3"}) as client: 328 | with pytest.raises(HTTPStatusError) as exception_info: 329 | client.tools.call("get_user_identity", {}) 330 | assert exception_info.value.response.status_code == 403 331 | 332 | # Authenticated but tool does not exist 333 | with get_sync_test_client(app, headers={"x-api-key": "1"}) as client: 334 | with pytest.raises(HTTPStatusError) as exception_info: 335 | client.tools.call("does_not_exist", {}) 336 | assert exception_info.value.response.status_code == 403 337 | 338 | # Not authenticated 339 | with get_sync_test_client(app, headers={"x-api-key": "6"}) as client: 340 | # Make sure this raises 401? 341 | with pytest.raises(HTTPStatusError) as exception_info: 342 | client.tools.call("does_not_exist", {}) 343 | 344 | assert exception_info.value.response.status_code == 401 345 | 346 | 347 | async def test_exposing_existing_langchain_tools() -> None: 348 | """Test exposing existing langchain tools.""" 349 | from langchain_core.tools import StructuredTool, tool 350 | 351 | @tool 352 | def say_hello_sync() -> str: 353 | """Say hello.""" 354 | return "Hello" 355 | 356 | @tool 357 | async def say_hello_async() -> str: 358 | """Say hello.""" 359 | return "Hello" 360 | 361 | def multiply(a: int, b: int) -> int: 362 | """Multiply two numbers.""" 363 | return a * b 364 | 365 | async def amultiply(a: int, b: int) -> int: 366 | """Multiply two numbers.""" 367 | return a * b 368 | 369 | calculator = StructuredTool.from_function(func=multiply, coroutine=amultiply) 370 | 371 | server = Server() 372 | auth = Auth() 373 | server.add_auth(auth) 374 | 375 | @auth.authenticate 376 | async def authenticate(headers: dict) -> dict: 377 | """Authenticate incoming requests.""" 378 | api_key = headers.get(b"x-api-key") 379 | 380 | api_key_to_user = { 381 | b"1": {"permissions": ["group1"], "identity": "some-user"}, 382 | } 383 | 384 | if not api_key or api_key not in api_key_to_user: 385 | raise auth.exceptions.HTTPException(detail="Not authorized") 386 | 387 | return api_key_to_user[api_key] 388 | 389 | server.add_tool(say_hello_sync, permissions=["group1"]) 390 | server.add_tool(say_hello_async, permissions=["group1"]) 391 | server.add_tool(calculator, permissions=["group1"]) 392 | 393 | with get_sync_test_client(server, headers={"x-api-key": "1"}) as client: 394 | tools = client.tools.list() 395 | assert tools == [ 396 | { 397 | "description": "Say hello.", 398 | "id": "say_hello_sync@1.0.0", 399 | "input_schema": {"properties": {}, "type": "object"}, 400 | "name": "say_hello_sync", 401 | "output_schema": {"type": "string"}, 402 | "version": "1.0.0", 403 | }, 404 | { 405 | "description": "Say hello.", 406 | "id": "say_hello_async@1.0.0", 407 | "input_schema": {"properties": {}, "type": "object"}, 408 | "name": "say_hello_async", 409 | "output_schema": {"type": "string"}, 410 | "version": "1.0.0", 411 | }, 412 | { 413 | "description": "Multiply two numbers.", 414 | "id": "multiply@1.0.0", 415 | "input_schema": { 416 | "properties": {"a": {"type": "integer"}, "b": {"type": "integer"}}, 417 | "required": ["a", "b"], 418 | "type": "object", 419 | }, 420 | "name": "multiply", 421 | "output_schema": {"type": "integer"}, 422 | "version": "1.0.0", 423 | }, 424 | ] 425 | 426 | result = client.tools.call("say_hello_sync", {}) 427 | assert result == { 428 | "call_id": AnyStr(), 429 | "value": "Hello", 430 | "success": True, 431 | } 432 | 433 | result = client.tools.call("say_hello_async", {}) 434 | assert result == { 435 | "call_id": AnyStr(), 436 | "value": "Hello", 437 | "success": True, 438 | } 439 | 440 | result = client.tools.call("multiply", {"a": 2, "b": 3}) 441 | assert result == { 442 | "call_id": AnyStr(), 443 | "value": 6, 444 | "success": True, 445 | } 446 | -------------------------------------------------------------------------------- /libs/server/tests/unit_tests/test_without_sdk.py: -------------------------------------------------------------------------------- 1 | """Test the server.""" 2 | 3 | from contextlib import asynccontextmanager 4 | from typing import Annotated, AsyncGenerator, Optional 5 | 6 | from fastapi import FastAPI 7 | from httpx import ASGITransport, AsyncClient 8 | from starlette.requests import Request 9 | 10 | from universal_tool_server import Server 11 | from universal_tool_server._version import __version__ 12 | from universal_tool_server.tools import InjectedRequest 13 | 14 | from ..unit_tests.utils import AnyStr 15 | 16 | 17 | @asynccontextmanager 18 | async def get_async_test_client( 19 | server: FastAPI, *, path: Optional[str] = None, raise_app_exceptions: bool = True 20 | ) -> AsyncGenerator[AsyncClient, None]: 21 | """Get an async client.""" 22 | url = "http://localhost:9999" 23 | if path: 24 | url += path 25 | transport = ASGITransport( 26 | app=server, 27 | raise_app_exceptions=raise_app_exceptions, 28 | ) 29 | async_client = AsyncClient(base_url=url, transport=transport) 30 | try: 31 | yield async_client 32 | finally: 33 | await async_client.aclose() 34 | 35 | 36 | async def test_health() -> None: 37 | app = Server() 38 | async with get_async_test_client(app) as client: 39 | response = await client.get("/health") 40 | response.raise_for_status() 41 | assert response.json() == {"status": "OK"} 42 | 43 | 44 | async def test_info() -> None: 45 | """Test info end-point.""" 46 | app = Server() 47 | async with get_async_test_client(app) as client: 48 | response = await client.get("/info") 49 | response.raise_for_status() 50 | json_data = response.json() 51 | assert json_data == { 52 | "version": __version__, 53 | } 54 | 55 | 56 | async def test_list_tools() -> None: 57 | """Test list tools.""" 58 | app = Server() 59 | async with get_async_test_client(app) as client: 60 | response = await client.get("/tools") 61 | response.raise_for_status() 62 | json_data = response.json() 63 | assert json_data == [] 64 | 65 | 66 | async def test_422() -> None: 67 | """Test 422 responses.""" 68 | app = Server() 69 | 70 | @app.add_tool() 71 | def echo(number: int) -> str: 72 | """Echo a message.""" 73 | return str(number) 74 | 75 | async with get_async_test_client(app) as client: 76 | response = await client.post("/tools/call", json={}) 77 | assert response.status_code == 422 78 | assert "message" in response.json() 79 | assert ( 80 | response.json()["message"] 81 | == "{'type': 'missing', 'loc': ('body', 'request'), 'msg': 'Field " 82 | "required', 'input': {}}" 83 | ) 84 | 85 | 86 | async def test_lifespan() -> None: 87 | import contextlib 88 | 89 | from starlette.testclient import TestClient 90 | 91 | calls = [] 92 | 93 | @contextlib.asynccontextmanager 94 | async def lifespan(app): 95 | calls.append("startup") 96 | yield {"foo": "bar"} 97 | calls.append("shutdown") 98 | 99 | app = Server(lifespan=lifespan) 100 | 101 | @app.add_tool() 102 | def what_is_foo(request: Annotated[Request, InjectedRequest]) -> str: 103 | """Get foo""" 104 | return request.state.foo 105 | 106 | # Using Starlette's TestClient to make sure that the lifespan is used. 107 | # Seems to not be supported with httpx's ASGITransport. 108 | with TestClient(app) as client: 109 | response = client.get("/health") 110 | assert response.status_code == 200 111 | assert calls == ["startup"] 112 | response = client.post( 113 | "/tools/call", json={"request": {"tool_id": "what_is_foo", "input": {}}} 114 | ) 115 | response.raise_for_status() 116 | result = response.json() 117 | assert result == { 118 | "value": "bar", 119 | "success": True, 120 | "call_id": AnyStr(), 121 | } 122 | 123 | assert calls == ["startup", "shutdown"] 124 | -------------------------------------------------------------------------------- /libs/server/tests/unit_tests/utils.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | 4 | class AnyStr: 5 | """A type that matches any string.""" 6 | 7 | def __eq__(self, other: Any) -> bool: 8 | return isinstance(other, str) 9 | -------------------------------------------------------------------------------- /libs/server/universal_tool_server/__init__.py: -------------------------------------------------------------------------------- 1 | from contextlib import asynccontextmanager 2 | from typing import Callable, Optional, Tuple, TypeVar, Union, overload 3 | 4 | from fastapi import FastAPI 5 | from fastapi.exceptions import RequestValidationError 6 | from starlette.middleware.authentication import AuthenticationMiddleware 7 | from starlette.types import Lifespan, Receive, Scope, Send 8 | 9 | from universal_tool_server import root 10 | from universal_tool_server._version import __version__ 11 | from universal_tool_server.auth import Auth 12 | from universal_tool_server.auth.middleware import ( 13 | ServerAuthenticationBackend, 14 | on_auth_error, 15 | ) 16 | from universal_tool_server.splash import SPLASH 17 | from universal_tool_server.tools import ( 18 | InjectedRequest, 19 | ToolHandler, 20 | create_tools_router, 21 | validation_exception_handler, 22 | ) 23 | 24 | T = TypeVar("T", bound=Callable) 25 | 26 | 27 | class Server: 28 | """LangChain tool server.""" 29 | 30 | def __init__( 31 | self, *, lifespan: Lifespan | None = None, enable_mcp: bool = False 32 | ) -> None: 33 | """Initialize the server.""" 34 | 35 | @asynccontextmanager 36 | async def full_lifespan(app: FastAPI): 37 | """A lifespan event that is called when the server starts.""" 38 | print(SPLASH) 39 | # yield whatever is inside the context manager 40 | if lifespan: 41 | async with lifespan(app) as stateful: 42 | yield stateful 43 | else: 44 | yield 45 | 46 | self.app = FastAPI( 47 | version=__version__, 48 | lifespan=full_lifespan, 49 | title="Universal Tool Server", 50 | ) 51 | 52 | # Add a global exception handler for validation errors 53 | self.app.exception_handler(RequestValidationError)(validation_exception_handler) 54 | # Routes that go under `/` 55 | self.app.include_router(root.router) 56 | # Create a tool handler 57 | self.tool_handler = ToolHandler() 58 | # Routes that go under `/tools` 59 | router = create_tools_router(self.tool_handler) 60 | self.app.include_router(router, prefix="/tools") 61 | 62 | self._auth = Auth() 63 | # Also create the tool handler. 64 | # For now, it's a global that's referenced by both MCP and /tools router 65 | # Routes that go under `/mcp` (Model Context Protocol) 66 | self._enable_mcp = enable_mcp 67 | 68 | if enable_mcp: 69 | from universal_tool_server.mcp import MCP_APP_PREFIX, create_mcp_app 70 | 71 | self.app.mount(MCP_APP_PREFIX, create_mcp_app(self.tool_handler)) 72 | 73 | @overload 74 | def add_tool( 75 | self, 76 | fn: T, 77 | *, 78 | permissions: list[str] | None = None, 79 | version: Union[int, str, Tuple[int, int, int]] = (1, 0, 0), 80 | ) -> T: ... 81 | 82 | @overload 83 | def add_tool( 84 | self, 85 | *, 86 | permissions: list[str] | None = None, 87 | version: Union[int, str, Tuple[int, int, int]] = (1, 0, 0), 88 | ) -> Callable[[T], T]: ... 89 | 90 | def add_tool( 91 | self, 92 | fn: Optional[T] = None, 93 | *, 94 | permissions: list[str] | None = None, 95 | version: Union[int, str, Tuple[int, int, int]] = (1, 0, 0), 96 | ) -> Union[T, Callable[[T], T]]: 97 | """Use to add a tool to the server. 98 | 99 | This method works as either a decorator or a function. 100 | 101 | Can be used both with and without parentheses: 102 | 103 | Example with parentheses: 104 | 105 | @app.add_tool() 106 | async def echo(msg: str) -> str: 107 | return msg + "!" 108 | 109 | Example without parentheses: 110 | 111 | @app.tool 112 | async def add(x: int, y: int) -> int: 113 | return x + y 114 | 115 | Example as a function: 116 | 117 | async def echo(msg: str) -> str: 118 | return msg + "!" 119 | 120 | app.add_tool(echo) 121 | """ 122 | 123 | def decorator(fn: T) -> T: 124 | self.tool_handler.add(fn, permissions=permissions, version=version) 125 | # Return the original. The decorator is only to register the tool. 126 | return fn 127 | 128 | if fn is not None: 129 | return decorator(fn) 130 | return decorator 131 | 132 | def add_auth(self, auth: Auth) -> None: 133 | """Add an authentication handler to the server.""" 134 | if not isinstance(auth, Auth): 135 | raise TypeError(f"Expected an instance of Auth, got {type(auth)}") 136 | 137 | if self._auth._authenticate_handler is not None: 138 | raise ValueError( 139 | "Please add an authentication handler before adding another one." 140 | ) 141 | 142 | # Make sure that the tool handler enables authentication checks. 143 | # Needed b/c Starlette's Request object raises assertion errors if 144 | # trying to access request.auth when auth is not enabled. 145 | self.tool_handler.auth_enabled = True 146 | 147 | if self._enable_mcp: 148 | raise AssertionError("MCPs Python SDK does not support authentication.") 149 | 150 | self.app.add_middleware( 151 | AuthenticationMiddleware, 152 | backend=ServerAuthenticationBackend(auth), 153 | on_error=on_auth_error, 154 | ) 155 | 156 | async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: 157 | """ASGI Application""" 158 | return await self.app.__call__(scope, receive, send) 159 | 160 | 161 | __all__ = ["__version__", "Server", "Auth", "InjectedRequest"] 162 | -------------------------------------------------------------------------------- /libs/server/universal_tool_server/_version.py: -------------------------------------------------------------------------------- 1 | from importlib import metadata 2 | 3 | try: 4 | __version__ = metadata.version(__package__) 5 | except metadata.PackageNotFoundError: 6 | # Case where package metadata is not available. 7 | __version__ = "" 8 | -------------------------------------------------------------------------------- /libs/server/universal_tool_server/auth/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import typing 4 | 5 | from universal_tool_server.auth import exceptions, types 6 | 7 | AH = typing.TypeVar("AH", bound=types.Authenticator) 8 | 9 | 10 | class Auth: 11 | """Add custom authentication and authorization management. 12 | 13 | The Auth class provides a unified system for handling authentication and 14 | authorization. 15 | 16 | ???+ example "Basic Usage" 17 | ```python 18 | from universal_tool_server.auth import Auth 19 | 20 | my_auth = Auth() 21 | 22 | async def verify_token(token: str) -> str: 23 | # Verify token and return user_id 24 | # This would typically be a call to your auth server 25 | return "user_id" 26 | 27 | @auth.authenticate 28 | async def authenticate(authorization: str) -> str: 29 | # Verify token and return user_id 30 | result = await verify_token(authorization) 31 | if result != "user_id": 32 | raise Auth.exceptions.HTTPException( 33 | status_code=401, detail="Unauthorized" 34 | ) 35 | return result 36 | 37 | ???+ note "Request Processing Flow" 38 | Authentication (your `@auth.authenticate` handler) is performed first 39 | on **every request** 40 | """ 41 | 42 | __slots__ = ("_authenticate_handler",) 43 | types = types 44 | """Reference to auth type definitions. 45 | 46 | Provides access to all type definitions used in the auth system, 47 | like ThreadsCreate, AssistantsRead, etc.""" 48 | 49 | exceptions = exceptions 50 | """Reference to auth exception definitions. 51 | 52 | Provides access to all exception definitions used in the auth system, 53 | like HTTPException, etc. 54 | """ 55 | 56 | def __init__(self) -> None: 57 | # These are accessed by the API. Changes to their names or types is 58 | # will be considered a breaking change. 59 | self._authenticate_handler: typing.Optional[types.Authenticator] = None 60 | 61 | def authenticate(self, fn: AH) -> AH: 62 | """Register an authentication handler function. 63 | 64 | The authentication handler is responsible for verifying credentials 65 | and returning user scopes. It can accept any of the following parameters 66 | by name: 67 | 68 | - request (Request): The raw ASGI request object 69 | - body (dict): The parsed request body 70 | - method (str): The HTTP method, e.g., "GET" 71 | - headers (dict[bytes, bytes]): Request headers 72 | - authorization (str | None): The Authorization header 73 | value (e.g., "Bearer ") 74 | 75 | Args: 76 | fn (Callable): The authentication handler function to register. 77 | Must return a representation of the user. This could be a: 78 | - string (the user id) 79 | - dict containing {"identity": str, "permissions": list[str]} 80 | - or an object with identity and permissions properties 81 | Permissions can be optionally used by your handlers downstream. 82 | 83 | Returns: 84 | The registered handler function. 85 | 86 | Raises: 87 | ValueError: If an authentication handler is already registered. 88 | 89 | ???+ example "Examples" 90 | Basic token authentication: 91 | ```python 92 | @auth.authenticate 93 | async def authenticate(authorization: str) -> str: 94 | user_id = verify_token(authorization) 95 | return user_id 96 | ``` 97 | 98 | Accept the full request context: 99 | ```python 100 | @auth.authenticate 101 | async def authenticate( 102 | method: str, 103 | path: str, 104 | headers: dict[bytes, bytes] 105 | ) -> str: 106 | user = await verify_request(method, path, headers) 107 | return user 108 | ``` 109 | """ 110 | if self._authenticate_handler is not None: 111 | raise ValueError( 112 | "Authentication handler already set as {self._authenticate_handler}." 113 | ) 114 | self._authenticate_handler = fn 115 | return fn 116 | 117 | 118 | __all__ = ["Auth", "types", "exceptions"] 119 | -------------------------------------------------------------------------------- /libs/server/universal_tool_server/auth/exceptions.py: -------------------------------------------------------------------------------- 1 | """Exceptions used in the auth system.""" 2 | 3 | import http 4 | import typing 5 | 6 | 7 | class HTTPException(Exception): 8 | """HTTP exception that you can raise to return a specific HTTP error response. 9 | 10 | Since this is defined in the auth module, we default to a 401 status code. 11 | 12 | Args: 13 | status_code (int, optional): HTTP status code for the error. 14 | Defaults to 401 "Unauthorized". 15 | detail (str | None, optional): Detailed error message. If None, uses a default 16 | message based on the status code. 17 | headers (typing.Mapping[str, str] | None, optional): Additional HTTP headers to 18 | include in the error response. 19 | 20 | Example: 21 | Default: 22 | 23 | ```python 24 | raise HTTPException() 25 | # HTTPException(status_code=401, detail='Unauthorized') 26 | ``` 27 | 28 | Add headers: 29 | ```python 30 | raise HTTPException(headers={"X-Custom-Header": "Custom Value"}) 31 | # HTTPException( 32 | # status_code=401, 33 | # detail='Unauthorized', 34 | # headers={"WWW-Authenticate": "Bearer"} 35 | # ) 36 | ``` 37 | 38 | Custom error: 39 | ```python 40 | raise HTTPException(status_code=404, detail="Not found") 41 | ``` 42 | """ 43 | 44 | def __init__( 45 | self, 46 | status_code: int = 401, 47 | detail: typing.Optional[str] = None, 48 | headers: typing.Optional[typing.Mapping[str, str]] = None, 49 | ) -> None: 50 | if detail is None: 51 | detail = http.HTTPStatus(status_code).phrase 52 | self.status_code = status_code 53 | self.detail = detail 54 | self.headers = headers 55 | 56 | def __str__(self) -> str: 57 | return f"{self.status_code}: {self.detail}" 58 | 59 | def __repr__(self) -> str: 60 | class_name = self.__class__.__name__ 61 | return f"{class_name}(status_code={self.status_code!r}, detail={self.detail!r})" 62 | 63 | 64 | __all__ = ["HTTPException"] 65 | -------------------------------------------------------------------------------- /libs/server/universal_tool_server/auth/middleware.py: -------------------------------------------------------------------------------- 1 | import copy 2 | import functools 3 | import inspect 4 | from collections.abc import Callable, Mapping 5 | from typing import Any 6 | 7 | from starlette.authentication import ( 8 | AuthCredentials, 9 | AuthenticationBackend, 10 | AuthenticationError, 11 | BaseUser, 12 | ) 13 | from starlette.concurrency import run_in_threadpool 14 | from starlette.exceptions import HTTPException 15 | from starlette.requests import HTTPConnection, Request 16 | from starlette.responses import JSONResponse, Response 17 | 18 | from universal_tool_server.auth import Auth 19 | 20 | SUPPORTED_PARAMETERS = { 21 | "request": Request, 22 | "body": dict, 23 | "user": BaseUser, 24 | "path": str, 25 | "method": str, 26 | "scopes": list[str], 27 | "path_params": dict[str, str] | None, 28 | "query_params": dict[str, str] | None, 29 | "headers": dict[str, bytes] | None, 30 | "authorization": str | None, 31 | "scope": dict[str, Any], 32 | } 33 | 34 | 35 | class ServerAuthenticationBackend(AuthenticationBackend): 36 | def __init__( 37 | self, 38 | auth: Auth, 39 | ) -> None: 40 | """Initializes the authentication backend.""" 41 | self.auth = auth 42 | self._fn = None 43 | self._param_names = None 44 | 45 | @property 46 | def fn(self) -> Callable: 47 | if self._fn is None: 48 | fn = self.auth._authenticate_handler 49 | if not inspect.iscoroutinefunction(fn): 50 | self._fn = functools.partial(run_in_threadpool, fn) 51 | else: 52 | self._fn = fn 53 | return self._fn 54 | 55 | @property 56 | def param_names(self) -> set[str]: 57 | if self._param_names is None: 58 | self._param_names = ( 59 | _get_named_arguments(self.fn, supported_params=SUPPORTED_PARAMETERS) 60 | if self.fn 61 | else None 62 | ) 63 | return self._param_names 64 | 65 | async def authenticate( 66 | self, conn: HTTPConnection 67 | ) -> tuple[AuthCredentials, BaseUser] | None: 68 | """Authenticate the request and return the user and permissions.""" 69 | if self.fn is None: 70 | return None 71 | try: 72 | args = _extract_arguments_from_scope( 73 | conn.scope, self.param_names, request=Request(conn.scope) 74 | ) 75 | response = await self.fn(**args) 76 | return _normalize_auth_response(response) 77 | except AuthenticationError: 78 | # Can be raised by the authentication handler and handled by the middleware 79 | raise 80 | except (Auth.exceptions.HTTPException, HTTPException) as e: 81 | # Needs to be translated to AuthenticationError to be handled by the 82 | # middleware. 83 | # Only translate 401 status code. 84 | if e.status_code == 401: 85 | raise AuthenticationError(e.detail) from None 86 | raise 87 | 88 | 89 | def _extract_arguments_from_scope( 90 | scope: dict[str, Any], 91 | param_names: set[str], 92 | request: Request | None = None, 93 | response: Response | None = None, 94 | ) -> dict[str, Any]: 95 | """Extract requested arguments from the ASGI scope (and request/response if needed).""" 96 | 97 | auth = scope.get("auth") 98 | args: dict[str, Any] = {} 99 | if "scope" in param_names: 100 | args["scope"] = scope 101 | if "request" in param_names and request is not None: 102 | args["request"] = request 103 | if "response" in param_names and response is not None: 104 | args["response"] = response 105 | if "user" in param_names: 106 | user = scope.get("user") 107 | args["user"] = user 108 | if "scopes" in param_names: 109 | args["scopes"] = auth.scopes if auth else [] 110 | if "path_params" in param_names: 111 | args["path_params"] = scope.get("path_params", {}) 112 | if "path" in param_names: 113 | args["path"] = scope["path"] 114 | if "query_params" in param_names: 115 | args["query_params"] = scope.get("query_params", {}) 116 | if "headers" in param_names: 117 | args["headers"] = dict(scope.get("headers", {})) 118 | if "authorization" in param_names: 119 | headers = dict(scope.get("headers", {})) 120 | authorization = headers.get(b"authorization") or headers.get(b"Authorization") 121 | if isinstance(authorization, bytes): 122 | authorization = authorization.decode(encoding="utf-8") 123 | args["authorization"] = authorization 124 | if "method" in param_names: 125 | args["method"] = scope.get("method") 126 | 127 | return args 128 | 129 | 130 | class DotDict: 131 | def __init__(self, dictionary: dict[str, Any]): 132 | self._dict = dictionary 133 | for key, value in dictionary.items(): 134 | if isinstance(value, dict): 135 | setattr(self, key, DotDict(value)) 136 | else: 137 | setattr(self, key, value) 138 | 139 | def __getattr__(self, name): 140 | if name not in self._dict: 141 | raise AttributeError(f"'DotDict' object has no attribute '{name}'") 142 | return self._dict[name] 143 | 144 | def __getitem__(self, key): 145 | return self._dict[key] 146 | 147 | def __setitem__(self, key, value): 148 | self._dict[key] = value 149 | if isinstance(value, dict): 150 | setattr(self, key, DotDict(value)) 151 | else: 152 | setattr(self, key, value) 153 | 154 | def __deepcopy__(self, memo): 155 | return DotDict(copy.deepcopy(self._dict)) 156 | 157 | def dict(self): 158 | return self._dict 159 | 160 | 161 | class ProxyUser(BaseUser): 162 | """A proxy that wraps a user object to ensure it has all BaseUser properties. 163 | 164 | This will: 165 | 1. Ensure the required identity property exists 166 | 2. Provide defaults for optional properties if they don't exist 167 | 3. Proxy all other attributes to the underlying user object 168 | """ 169 | 170 | def __init__(self, user: Any): 171 | if not hasattr(user, "identity"): 172 | raise ValueError("User must have an identity property") 173 | self._user = user 174 | 175 | @property 176 | def identity(self) -> str: 177 | return self._user.identity 178 | 179 | @property 180 | def is_authenticated(self) -> bool: 181 | return getattr(self._user, "is_authenticated", True) 182 | 183 | @property 184 | def display_name(self) -> str: 185 | return getattr(self._user, "display_name", self.identity) 186 | 187 | def __deepcopy__(self, memo): 188 | return ProxyUser(copy.deepcopy(self._user)) 189 | 190 | def model_dump(self): 191 | if hasattr(self._user, "model_dump") and callable(self._user.model_dump): 192 | return { 193 | "identity": self.identity, 194 | "is_authenticated": self.is_authenticated, 195 | "display_name": self.display_name, 196 | **self._user.model_dump(mode="json"), 197 | } 198 | return self.dict() 199 | 200 | def dict(self): 201 | d = ( 202 | self._user.dict() 203 | if hasattr(self._user, "dict") and callable(self._user.dict) 204 | else {} 205 | ) 206 | return { 207 | "identity": self.identity, 208 | "is_authenticated": self.is_authenticated, 209 | "display_name": self.display_name, 210 | **d, 211 | } 212 | 213 | def __getitem__(self, key): 214 | return self._user[key] 215 | 216 | def __setitem__(self, key, value): 217 | self._user[key] = value 218 | 219 | def __getattr__(self, name: str) -> Any: 220 | """Proxy any other attributes to the underlying user object.""" 221 | return getattr(self._user, name) 222 | 223 | 224 | class SimpleUser(ProxyUser): 225 | def __init__(self, username: str): 226 | super().__init__(DotDict({"identity": username})) 227 | 228 | 229 | def _normalize_auth_response( 230 | response: Any, 231 | ) -> tuple[AuthCredentials, BaseUser]: 232 | if isinstance(response, tuple): 233 | if len(response) != 2: 234 | raise ValueError( 235 | f"Expected a tuple with two elements (permissions, user), got {len(response)}" 236 | ) 237 | permissions, user = response 238 | elif hasattr(response, "permissions"): 239 | permissions = response.permissions 240 | user = response 241 | elif isinstance(response, dict | Mapping) and "permissions" in response: 242 | permissions = response["permissions"] 243 | user = response 244 | else: 245 | user = response 246 | permissions = [] 247 | 248 | return AuthCredentials(permissions), normalize_user(user) 249 | 250 | 251 | def normalize_user(user: Any) -> BaseUser: 252 | """Normalize user into a BaseUser instance.""" 253 | if isinstance(user, BaseUser): 254 | return user 255 | if hasattr(user, "identity"): 256 | return ProxyUser(user) 257 | if isinstance(user, str): 258 | return SimpleUser(username=user) 259 | if isinstance(user, dict) and "identity" in user: 260 | return ProxyUser(DotDict(user)) 261 | raise ValueError( 262 | f"Expected a BaseUser instance with required property: identity (str). " 263 | f"Optional properties are: is_authenticated (bool, defaults to True) and " 264 | f"display_name (str, defaults to identity). Got {type(user)} instead" 265 | ) 266 | 267 | 268 | def _get_named_arguments(fn: Callable, supported_params: dict) -> set[str]: 269 | """Get the named arguments that a function accepts, ensuring they're supported.""" 270 | sig = inspect.signature(fn) 271 | # Check for unsupported required parameters 272 | unsupported = [] 273 | for name, param in sig.parameters.items(): 274 | if name not in supported_params and param.default is param.empty: 275 | unsupported.append(name) 276 | 277 | if unsupported: 278 | supported_str = "\n".join( 279 | f" - {name} ({getattr(typ, '__name__', str(typ))})" 280 | for name, typ in supported_params.items() 281 | ) 282 | raise ValueError( 283 | f"Handler has unsupported required parameters: {', '.join(unsupported)}.\n" 284 | f"Supported parameters are:\n{supported_str}" 285 | ) 286 | 287 | return {p for p in sig.parameters if p in supported_params} 288 | 289 | 290 | def on_auth_error(request: Request, exc: AuthenticationError) -> JSONResponse: 291 | """Handle authentication errors.""" 292 | return JSONResponse({"error": str(exc)}, status_code=401) 293 | -------------------------------------------------------------------------------- /libs/server/universal_tool_server/auth/types.py: -------------------------------------------------------------------------------- 1 | """Authentication middleware decorator. 2 | 3 | This module defines the core types used for authentication, authorization, and 4 | request handling. 5 | 6 | It includes user protocols, authentication contexts, and typed 7 | dictionaries for various API operations. 8 | """ 9 | 10 | import functools 11 | import sys 12 | import typing 13 | from collections.abc import Awaitable, Callable, Sequence 14 | from dataclasses import dataclass 15 | 16 | import typing_extensions 17 | 18 | T = typing.TypeVar("T") 19 | 20 | 21 | def _slotify(fn: T) -> T: 22 | """Add slots to a dataclass if supported.""" 23 | if sys.version_info >= (3, 10): # noqa: UP036 24 | return functools.partial(fn, slots=True) # type: ignore 25 | return fn 26 | 27 | 28 | dataclass = _slotify(dataclass) 29 | 30 | 31 | @typing.runtime_checkable 32 | class MinimalUser(typing.Protocol): 33 | """User objects must at least expose the identity property.""" 34 | 35 | @property 36 | def identity(self) -> str: 37 | """The unique identifier for the user. 38 | 39 | This could be a username, email, or any other unique identifier used 40 | to distinguish between different users in the system. 41 | """ 42 | ... 43 | 44 | 45 | class MinimalUserDict(typing.TypedDict, total=False): 46 | """The dictionary representation of a user.""" 47 | 48 | identity: typing_extensions.Required[str] 49 | """The required unique identifier for the user.""" 50 | display_name: str 51 | """The typing.Optional display name for the user.""" 52 | is_authenticated: bool 53 | """Whether the user is authenticated. Defaults to True.""" 54 | permissions: Sequence[str] 55 | """A list of permissions associated with the user. 56 | 57 | You can use these in your `@auth.on` authorization logic to determine 58 | access permissions to different resources. 59 | """ 60 | 61 | 62 | @typing.runtime_checkable 63 | class BaseUser(typing.Protocol): 64 | """The base ASGI user protocol""" 65 | 66 | @property 67 | def is_authenticated(self) -> bool: 68 | """Whether the user is authenticated.""" 69 | ... 70 | 71 | @property 72 | def display_name(self) -> str: 73 | """The display name of the user.""" 74 | ... 75 | 76 | @property 77 | def identity(self) -> str: 78 | """The unique identifier for the user.""" 79 | ... 80 | 81 | @property 82 | def permissions(self) -> Sequence[str]: 83 | """The permissions associated with the user.""" 84 | ... 85 | 86 | 87 | Authenticator = Callable[ 88 | ..., 89 | Awaitable[ 90 | typing.Union[ 91 | MinimalUser, str, BaseUser, MinimalUserDict, typing.Mapping[str, typing.Any] 92 | ], 93 | ], 94 | ] 95 | """Type for authentication functions. 96 | 97 | An authenticator can return either: 98 | 1. A string (user_id) 99 | 2. A dict containing {"identity": str, "permissions": list[str]} 100 | 3. An object with identity and permissions properties 101 | 102 | Permissions can be used downstream by your authorization logic to determine 103 | access permissions to different resources. 104 | 105 | The authenticate decorator will automatically inject any of the following parameters 106 | by name if they are included in your function signature: 107 | 108 | Parameters: 109 | request (Request): The raw ASGI request object 110 | body (dict): The parsed request body 111 | path (str): The request path 112 | method (str): The HTTP method (GET, POST, etc.) 113 | path_params (dict[str, str] | None): URL path parameters 114 | query_params (dict[str, str] | None): URL query parameters 115 | headers (dict[str, bytes] | None): Request headers 116 | authorization (str | None): The Authorization header value (e.g. "Bearer ") 117 | 118 | ???+ example "Examples" 119 | Basic authentication with token: 120 | ```python 121 | from universal_tool_server.auth import Auth 122 | 123 | auth = Auth() 124 | 125 | @auth.authenticate 126 | async def authenticate1(authorization: str) -> Auth.types.MinimalUserDict: 127 | return await get_user(authorization) 128 | ``` 129 | 130 | Authentication with multiple parameters: 131 | ``` 132 | @auth.authenticate 133 | async def authenticate2( 134 | method: str, 135 | path: str, 136 | headers: dict[str, bytes] 137 | ) -> Auth.types.MinimalUserDict: 138 | # Custom auth logic using method, path and headers 139 | user = verify_request(method, path, headers) 140 | return user 141 | ``` 142 | 143 | Accepting the raw ASGI request: 144 | ```python 145 | MY_SECRET = "my-secret-key" 146 | @auth.authenticate 147 | async def get_current_user(request: Request) -> Auth.types.MinimalUserDict: 148 | try: 149 | token = (request.headers.get("authorization") or "").split(" ", 1)[1] 150 | payload = jwt.decode(token, MY_SECRET, algorithms=["HS256"]) 151 | except (IndexError, InvalidTokenError): 152 | raise HTTPException( 153 | status_code=401, 154 | detail="Invalid token", 155 | headers={"WWW-Authenticate": "Bearer"}, 156 | ) 157 | 158 | async with httpx.AsyncClient() as client: 159 | response = await client.get( 160 | f"https://api.myauth-provider.com/auth/v1/user", 161 | headers={"Authorization": f"Bearer {MY_SECRET}"} 162 | ) 163 | if response.status_code != 200: 164 | raise HTTPException(status_code=401, detail="User not found") 165 | 166 | user_data = response.json() 167 | return { 168 | "identity": user_data["id"], 169 | "display_name": user_data.get("name"), 170 | "permissions": user_data.get("permissions", []), 171 | "is_authenticated": True, 172 | } 173 | ``` 174 | """ 175 | -------------------------------------------------------------------------------- /libs/server/universal_tool_server/mcp.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import json 4 | from itertools import chain 5 | from typing import TYPE_CHECKING, Any, Sequence 6 | 7 | from starlette.applications import Starlette 8 | from starlette.requests import Request 9 | from starlette.routing import Mount, Route 10 | 11 | from universal_tool_server.tools import CallToolRequest, ToolHandler 12 | 13 | if TYPE_CHECKING: 14 | from mcp.types import EmbeddedResource, ImageContent, TextContent 15 | 16 | MCP_APP_PREFIX = "/mcp" 17 | 18 | 19 | def _convert_to_content( 20 | result: Any, 21 | ) -> Sequence[TextContent | ImageContent | EmbeddedResource]: 22 | """Convert a result to a sequence of content objects.""" 23 | # This code comes directly from the FastMCP server. 24 | # Imported here as it is a private function. 25 | import pydantic_core 26 | from mcp.server.fastmcp.utilities.types import Image 27 | from mcp.types import EmbeddedResource, ImageContent, TextContent 28 | 29 | if result is None: 30 | return [] 31 | 32 | if isinstance(result, (TextContent, ImageContent, EmbeddedResource)): 33 | return [result] 34 | 35 | if isinstance(result, Image): 36 | return [result.to_image_content()] 37 | 38 | if isinstance(result, (list, tuple)): 39 | return list(chain.from_iterable(_convert_to_content(item) for item in result)) 40 | 41 | if not isinstance(result, str): 42 | try: 43 | result = json.dumps(pydantic_core.to_jsonable_python(result)) 44 | except Exception: 45 | result = str(result) 46 | 47 | return [TextContent(type="text", text=result)] 48 | 49 | 50 | def create_mcp_app(tool_handler: ToolHandler) -> Starlette: 51 | """Create a Starlette app for an MCP server.""" 52 | from mcp.server.lowlevel import Server as MCPServer 53 | from mcp.server.sse import SseServerTransport 54 | from mcp.types import Tool 55 | 56 | sse = SseServerTransport(f"{MCP_APP_PREFIX}/messages/") 57 | server = MCPServer(name="MCP Server") 58 | 59 | @server.list_tools() 60 | async def list_tools() -> list[Tool]: 61 | """List available tools.""" 62 | # The original request object is not currently available in the MCP server. 63 | # We'll send a None for the request object. 64 | # This means that if Auth is enabled, the MCP endpoint will not 65 | # list any tools that require authentication. 66 | 67 | tools = [] 68 | 69 | for tool in await tool_handler.list_tools(request=None): 70 | # MCP has no concept of tool versions, so we'll only 71 | # return the latest version. 72 | if tool_handler.latest_version[tool["name"]]["id"] != tool["id"]: 73 | continue 74 | 75 | tools.append( 76 | Tool( 77 | name=tool["name"], 78 | description=tool["description"], 79 | inputSchema=tool["input_schema"], 80 | ) 81 | ) 82 | 83 | return tools 84 | 85 | @server.call_tool() 86 | async def call_tool( 87 | name: str, arguments: dict[str, Any] 88 | ) -> Sequence[TextContent | ImageContent | EmbeddedResource]: 89 | """Call a tool by name with arguments.""" 90 | # The original request object is not currently available in the MCP server. 91 | # We'll send a None for the request object. 92 | # This means that if Auth is enabled, the MCP endpoint will not 93 | # list any tools that require authentication. 94 | call_tool_request: CallToolRequest = { 95 | "tool_id": name, 96 | "input": arguments, 97 | } 98 | response = await tool_handler.call_tool(call_tool_request, request=None) 99 | if not response["success"]: 100 | raise NotImplementedError( 101 | "Support for error messages is not yet implemented." 102 | ) 103 | return _convert_to_content(response["value"]) 104 | 105 | async def handle_sse(request: Request): 106 | async with sse.connect_sse( 107 | request.scope, request.receive, request._send 108 | ) as streams: 109 | await server.run( 110 | streams[0], streams[1], server.create_initialization_options() 111 | ) 112 | 113 | starlette_app = Starlette( 114 | routes=[ 115 | Route("/sse", endpoint=handle_sse), 116 | Mount("/messages/", app=sse.handle_post_message), 117 | ], 118 | ) 119 | return starlette_app 120 | -------------------------------------------------------------------------------- /libs/server/universal_tool_server/root.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter 2 | from fastapi.responses import HTMLResponse 3 | from typing_extensions import TypedDict 4 | 5 | from universal_tool_server._version import __version__ 6 | 7 | from .splash import SPLASH 8 | 9 | 10 | class InfoResponse(TypedDict): 11 | """Get information about the server.""" 12 | 13 | version: str 14 | 15 | 16 | router = APIRouter() 17 | 18 | 19 | @router.get("/", response_class=HTMLResponse) 20 | async def index() -> str: 21 | SPLASH_HTML = SPLASH.replace("\n", "
") 22 | html_content = f""" 23 | 24 | 25 | Universal Tool Server 26 | 27 | 28 |
29 |

30 | {SPLASH_HTML} 31 |

32 |
33 |
34 |

35 |

39 |

40 |
41 | 42 | 43 | """ 44 | 45 | return html_content 46 | 47 | 48 | @router.get("/info") 49 | def get_info() -> InfoResponse: 50 | """Get information about the server.""" 51 | return {"version": __version__} 52 | 53 | 54 | @router.get("/health") 55 | def health() -> dict: 56 | """Are we OK?""" 57 | return {"status": "OK"} 58 | -------------------------------------------------------------------------------- /libs/server/universal_tool_server/splash.py: -------------------------------------------------------------------------------- 1 | SPLASH = """\ 2 | 3 | ██╗ ██╗███╗ ██╗██╗██╗ ██╗███████╗██████╗ ███████╗ █████╗ ██╗ 4 | ██║ ██║████╗ ██║██║██║ ██║██╔════╝██╔══██╗██╔════╝██╔══██╗██║ 5 | ██║ ██║██╔██╗ ██║██║██║ ██║█████╗ ██████╔╝███████╗███████║██║ 6 | ██║ ██║██║╚██╗██║██║╚██╗ ██╔╝██╔══╝ ██╔══██╗╚════██║██╔══██║██║ 7 | ╚██████╔╝██║ ╚████║██║ ╚████╔╝ ███████╗██║ ██║███████║██║ ██║███████╗ 8 | ╚═════╝ ╚═╝ ╚═══╝╚═╝ ╚═══╝ ╚══════╝╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝╚══════╝ 9 | 10 | ████████╗ ██████╗ ██████╗ ██╗ 11 | ╚══██╔══╝██╔═══██╗██╔═══██╗██║ 12 | ██║ ██║ ██║██║ ██║██║ 13 | ██║ ██║ ██║██║ ██║██║ 14 | ██║ ╚██████╔╝╚██████╔╝███████╗ 15 | ╚═╝ ╚═════╝ ╚═════╝ ╚══════╝ 16 | 17 | ███████╗███████╗██████╗ ██╗ ██╗███████╗██████╗ 18 | ██╔════╝██╔════╝██╔══██╗██║ ██║██╔════╝██╔══██╗ 19 | ███████╗█████╗ ██████╔╝██║ ██║█████╗ ██████╔╝ 20 | ╚════██║██╔══╝ ██╔══██╗╚██╗ ██╔╝██╔══╝ ██╔══██╗ 21 | ███████║███████╗██║ ██║ ╚████╔╝ ███████╗██║ ██║ 22 | ╚══════╝╚══════╝╚═╝ ╚═╝ ╚═══╝ ╚══════╝╚═╝ ╚═╝ 23 | """ 24 | -------------------------------------------------------------------------------- /libs/server/universal_tool_server/tools.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from typing import ( 3 | Any, 4 | Awaitable, 5 | Callable, 6 | Dict, 7 | Literal, 8 | Union, 9 | cast, 10 | get_type_hints, 11 | ) 12 | 13 | import structlog 14 | from fastapi import APIRouter, HTTPException, Request, status 15 | from fastapi.encoders import jsonable_encoder 16 | from fastapi.exceptions import RequestValidationError 17 | from fastapi.responses import JSONResponse 18 | from jsonschema_rs import validator_for 19 | from langchain_core.tools import BaseTool, InjectedToolArg, StructuredTool 20 | from langchain_core.tools import tool as tool_decorator 21 | from langchain_core.utils.function_calling import convert_to_openai_function 22 | from pydantic import BaseModel, Field, TypeAdapter 23 | from typing_extensions import NotRequired, TypedDict 24 | 25 | 26 | class RegisteredTool(TypedDict): 27 | """A registered tool.""" 28 | 29 | id: str 30 | """Unique identifier for the tool.""" 31 | name: str 32 | """Name of the tool.""" 33 | description: str 34 | """Description of the tool.""" 35 | input_schema: Dict[str, Any] 36 | """Input schema of the tool.""" 37 | output_schema: Dict[str, Any] 38 | """Output schema of the tool.""" 39 | fn: Callable[[Dict[str, Any]], Awaitable[Any]] 40 | """Function to call the tool.""" 41 | permissions: set[str] 42 | """Scopes required to call the tool. 43 | 44 | If empty, not permissions are required and the tool is considered to be public. 45 | """ 46 | accepts: list[tuple[str, Any]] 47 | """List of run time arguments that the fn accepts. 48 | 49 | For example, a signature like def foo(x: int, request: Request) -> str:`, 50 | 51 | would have an entry in the `accepts` list as ("request", Request). 52 | """ 53 | version: tuple[int, int, int] 54 | """Version of the tool. Allows for semver versioning of tools. 55 | 56 | The version is a tuple of three integers: (major, minor, patch). 57 | 58 | A version of 1 will be represented as (1, 0, 0). 59 | """ 60 | 61 | 62 | def _is_allowed( 63 | tool: RegisteredTool, request: Request | None, auth_enabled: bool 64 | ) -> bool: 65 | """Check if the request has required permissions to see / use the tool.""" 66 | required_permissions = tool["permissions"] 67 | 68 | # If tool requests Request object, but one is not provided, then the tool is not 69 | # allowed. 70 | for _, type_ in tool["accepts"]: 71 | if type_ is Request and request is None: 72 | return False 73 | 74 | if not auth_enabled or not required_permissions: 75 | # Used to avoid request.auth attribute access raising an assertion errors 76 | # when no auth middleware is enabled.. 77 | return True 78 | permissions = request.auth.scopes if hasattr(request, "auth") else set() 79 | return required_permissions.issubset(permissions) 80 | 81 | 82 | class CallToolRequest(TypedDict): 83 | """Request to call a tool.""" 84 | 85 | tool_id: str 86 | """An unique identifier for the tool to call.""" 87 | input: NotRequired[Dict[str, Any]] 88 | """The input to pass to the tool.""" 89 | call_id: NotRequired[str] 90 | """Execution ID.""" 91 | trace_id: NotRequired[str] 92 | """Trace ID.""" 93 | 94 | 95 | # Not using `class` syntax b/c $schema is not a valid attribute name. 96 | class CallToolFullRequest(BaseModel): 97 | """Full request to call a tool.""" 98 | 99 | # The protocol schema will temporarily allow otc://1.0 for backwards compatibility. 100 | # This is expected to be removed in the near future as it is not part of the 101 | # official spec. 102 | protocol_schema: Union[Literal["urn:oxp:1.0"], str] = Field( 103 | default="urn:oxp:1.0", 104 | description="Protocol version.", 105 | alias="$schema", 106 | ) 107 | request: CallToolRequest = Field(..., description="Request to call a tool.") 108 | 109 | 110 | class ToolError(TypedDict): 111 | """Error message from the tool.""" 112 | 113 | message: str 114 | """Error message for the user or AI model.""" 115 | developer_message: NotRequired[str] 116 | """Internal error message for logging/debugging.""" 117 | can_retry: NotRequired[bool] 118 | """Indicates whether the tool call can be retried.""" 119 | additional_prompt_content: NotRequired[str] 120 | """Extra content to include in a retry prompt.""" 121 | retry_after_ms: NotRequired[int] 122 | """Time in milliseconds to wait before retrying.""" 123 | 124 | 125 | class ToolException(Exception): 126 | """An exception that can be raised by a tool.""" 127 | 128 | def __init__( 129 | self, 130 | *, 131 | user_message: str = "", 132 | developer_message: str = "", 133 | can_retry: bool = False, 134 | additional_prompt_content: str = "", 135 | retry_after_ms: int = 0, 136 | ) -> None: 137 | """Initializes the tool exception.""" 138 | self.message = user_message 139 | self.developer_message = developer_message 140 | self.can_retry = can_retry 141 | self.additional_prompt_content = additional_prompt_content 142 | self.retry_after_ms = retry_after_ms 143 | 144 | 145 | class CallToolResponse(TypedDict): 146 | """Response from a tool execution.""" 147 | 148 | call_id: str 149 | """A unique ID for the execution""" 150 | 151 | success: bool 152 | """Whether the execution was successful.""" 153 | 154 | value: NotRequired[Any] 155 | """The output of the tool execution.""" 156 | 157 | error: NotRequired[ToolError] 158 | """Error message from the tool.""" 159 | 160 | 161 | class ToolDefinition(TypedDict): 162 | """Used in the response of the list tools endpoint.""" 163 | 164 | id: str 165 | """Unique identifier for the tool.""" 166 | 167 | name: str 168 | """The name of the tool.""" 169 | 170 | description: str 171 | """A human-readable explanation of the tool's purpose.""" 172 | 173 | input_schema: Dict[str, Any] 174 | """The input schema of the tool. This is a JSON schema.""" 175 | 176 | output_schema: Dict[str, Any] 177 | """The output schema of the tool. This is a JSON schema.""" 178 | 179 | version: str 180 | """Version of the tool. Allows for semver versioning of tools.""" 181 | 182 | 183 | def _normalize_version( 184 | version: int | str | tuple[int, int, int], 185 | ) -> tuple[int, int, int]: 186 | """Normalize the version to a tuple of 3 integers, padding zeros if necessary.""" 187 | if isinstance(version, int): 188 | if version < 0: 189 | raise ValueError(f"Invalid version format: `{version}`") 190 | return version, 0, 0 191 | 192 | if isinstance(version, str): 193 | version_parts = version.split(".") 194 | elif isinstance(version, (tuple, list)): 195 | version_parts = list(version) 196 | else: 197 | raise ValueError(f"Invalid version format: `{version}`") 198 | 199 | if not 1 <= len(version_parts) <= 3: 200 | raise ValueError(f"Invalid version format: `{version}`") 201 | 202 | # Pad with zeros using faster concatenation 203 | version_parts += [0] * (3 - len(version_parts)) 204 | 205 | version_tuple = tuple(map(int, version_parts)) 206 | 207 | if any(x < 0 for x in version_tuple): 208 | raise ValueError(f"Invalid version format: `{version}`") 209 | 210 | return cast(tuple[int, int, int], version_tuple) 211 | 212 | 213 | class ToolHandler: 214 | def __init__(self) -> None: 215 | """Initializes the tool handler.""" 216 | self.catalog: Dict[str, RegisteredTool] = {} 217 | self.auth_enabled = False 218 | # Mapping from tool name to the latest version of the tool. 219 | self.latest_version: Dict[str, RegisteredTool] = {} 220 | 221 | def add( 222 | self, 223 | tool: Union[BaseTool, Callable], 224 | *, 225 | permissions: list[str] | None = None, 226 | # Default to version 1.0.0 227 | version: Union[int, str, tuple[int, int, int]] = (1, 0, 0), 228 | ) -> None: 229 | """Register a tool in the catalog. 230 | 231 | Args: 232 | tool: Implementation of the tool to register. 233 | version: Version of the tool. 234 | permissions: Permissions required to call the tool. 235 | """ 236 | # If not already a BaseTool, we'll convert it to one using 237 | # the tool decorator. 238 | if not isinstance(tool, BaseTool): 239 | tool = tool_decorator(tool) 240 | 241 | if isinstance(tool, BaseTool): 242 | from pydantic import BaseModel 243 | 244 | if not issubclass(tool.args_schema, BaseModel): 245 | raise NotImplementedError( 246 | "Expected args_schema to be a Pydantic model. " 247 | f"Got {type(tool.args_schema)}." 248 | "This is not yet supported." 249 | ) 250 | 251 | accepts = [] 252 | for name, field in tool.args_schema.model_fields.items(): 253 | if field.annotation is Request: 254 | accepts.append((name, Request)) 255 | 256 | output_schema = get_output_schema(tool) 257 | 258 | version = _normalize_version(version) 259 | version_str = ".".join(map(str, version)) 260 | 261 | registered_tool = { 262 | "id": f"{tool.name}@{version_str}", 263 | "name": tool.name, 264 | "description": tool.description, 265 | "input_schema": convert_to_openai_function(tool)["parameters"], 266 | "output_schema": output_schema, 267 | "fn": cast(Callable[[Dict[str, Any]], Awaitable[Any]], tool.ainvoke), 268 | "permissions": cast(set[str], set(permissions or [])), 269 | "accepts": accepts, 270 | # Register everything as version 1.0.0 for now. 271 | "version": version, 272 | } 273 | else: 274 | raise AssertionError("Reached unreachable code") 275 | 276 | if registered_tool["id"] in self.catalog: 277 | # Add unique ID to support duplicated tools? 278 | raise ValueError(f"Tool {registered_tool['id']} already exists") 279 | self.catalog[registered_tool["id"]] = registered_tool 280 | # Add the latest version of the tool to the latest_version mapping. 281 | name = registered_tool["name"] 282 | if name in self.latest_version: 283 | latest_version = self.latest_version[name] 284 | latest_version_version = latest_version["version"] 285 | if version > latest_version_version: 286 | self.latest_version[name] = registered_tool 287 | else: 288 | self.latest_version[name] = registered_tool 289 | 290 | async def call_tool( 291 | self, call_tool_request: CallToolRequest, request: Request | None 292 | ) -> CallToolResponse: 293 | """Calls a tool by name with the provided payload.""" 294 | tool_id = call_tool_request["tool_id"] 295 | 296 | # Extract version from tool_id 297 | components = tool_id.rsplit("@") 298 | if len(components) == 1: 299 | # No version specified, interpret as the name of the tool. 300 | name = components[0] 301 | if name not in self.latest_version: 302 | if self.auth_enabled: 303 | raise HTTPException( 304 | status_code=403, 305 | detail="Tool either does not exist or insufficient permissions", 306 | ) 307 | 308 | raise HTTPException(status_code=404, detail=f"Tool {name} not found") 309 | tool_id = self.latest_version[name]["id"] 310 | elif len(components) == 2: 311 | name, version = components 312 | normalized_version = _normalize_version(version) 313 | tool_id = f"{name}@{'.'.join(map(str, normalized_version))}" 314 | else: 315 | raise HTTPException( 316 | status_code=422, 317 | detail=( 318 | "Invalid tool ID. Tool ID must be in the format `name@version`. " 319 | "The version is optional and defaults to the latest version. " 320 | "If specified the version must be " 321 | "in the format `major.minor.patch` or `major`.", 322 | ), 323 | ) 324 | 325 | args = call_tool_request.get("input", {}) 326 | call_id = call_tool_request.get("call_id", uuid.uuid4()) 327 | 328 | if tool_id not in self.catalog: 329 | if self.auth_enabled: 330 | raise HTTPException( 331 | status_code=403, 332 | detail="Tool either does not exist or insufficient permissions", 333 | ) 334 | 335 | raise HTTPException(status_code=404, detail=f"Tool {tool_id} not found") 336 | 337 | tool = self.catalog[tool_id] 338 | 339 | if not _is_allowed(tool, request, self.auth_enabled): 340 | raise HTTPException( 341 | status_code=403, 342 | detail="Tool either does not exist or insufficient permissions", 343 | ) 344 | 345 | # Validate and parse the payload according to the tool's input schema. 346 | fn = tool["fn"] 347 | 348 | injected_arguments = {} 349 | 350 | accepts = tool["accepts"] 351 | 352 | for name, field in accepts: 353 | if field is Request: 354 | injected_arguments[name] = request 355 | 356 | if isinstance(fn, Callable): 357 | payload_schema_ = tool["input_schema"] 358 | validator = validator_for(payload_schema_) 359 | if not validator.is_valid(args): 360 | raise HTTPException( 361 | status_code=400, 362 | detail=( 363 | f"Invalid payload for tool call to tool {tool_id} " 364 | f"with args {args} and schema {payload_schema_}", 365 | ), 366 | ) 367 | # Update the injected arguments post-validation 368 | args.update(injected_arguments) 369 | tool_output = await fn(args) 370 | else: 371 | # This is an internal error 372 | raise AssertionError(f"Invalid tool implementation: {type(fn)}") 373 | 374 | return {"success": True, "call_id": str(call_id), "value": tool_output} 375 | 376 | async def list_tools(self, request: Request | None) -> list[ToolDefinition]: 377 | """Lists all available tools in the catalog.""" 378 | # Incorporate default permissions for the tools. 379 | tool_definitions = [] 380 | 381 | for tool in self.catalog.values(): 382 | if _is_allowed(tool, request, self.auth_enabled): 383 | tool_definition = { 384 | "id": tool["id"], 385 | "name": tool["name"], 386 | "description": tool["description"], 387 | "input_schema": tool["input_schema"], 388 | "output_schema": tool["output_schema"], 389 | "version": ".".join(map(str, tool["version"])), 390 | } 391 | 392 | tool_definitions.append(tool_definition) 393 | 394 | return tool_definitions 395 | 396 | 397 | class ValidationErrorResponse(TypedDict): 398 | """Validation error response.""" 399 | 400 | message: str 401 | 402 | 403 | def create_tools_router(tool_handler: ToolHandler) -> APIRouter: 404 | """Creates an API router for tools.""" 405 | router = APIRouter() 406 | 407 | @router.get( 408 | "", 409 | operation_id="list-tools", 410 | responses={ 411 | 200: {"model": list[ToolDefinition]}, 412 | 422: {"model": ValidationErrorResponse}, 413 | }, 414 | ) 415 | async def list_tools(request: Request) -> list[ToolDefinition]: 416 | """Lists available tools.""" 417 | return await tool_handler.list_tools(request) 418 | 419 | @router.post("/call", operation_id="call-tool") 420 | async def call_tool( 421 | call_tool_request: CallToolFullRequest, request: Request 422 | ) -> CallToolResponse: 423 | """Call a tool by name with the provided payload.""" 424 | if call_tool_request.protocol_schema not in {"urn:oxp:1.0", "otc://1.0"}: 425 | raise HTTPException( 426 | status_code=400, 427 | detail="Invalid protocol schema. Expected 'urn:oxp:1.0'.", 428 | ) 429 | return await tool_handler.call_tool(call_tool_request.request, request) 430 | 431 | return router 432 | 433 | 434 | class InjectedRequest(InjectedToolArg): 435 | """Annotation for injecting the starlette request object. 436 | 437 | Example: 438 | ..code-block:: python 439 | 440 | from typing import Annotated 441 | from universal_tool_server.server.tools import InjectedRequest 442 | from starlette.requests import Request 443 | 444 | @app.tool(permissions=["group1"]) 445 | async def who_am_i(request: Annotated[Request, InjectedRequest]) -> str: 446 | \"\"\"Return the user's identity\"\"\" 447 | # The `user` attribute can be used to retrieve the user object. 448 | # This object corresponds to the return value of the authentication 449 | # function. 450 | return request.user.identity 451 | """ 452 | 453 | 454 | logger = structlog.getLogger(__name__) 455 | 456 | 457 | def get_output_schema(tool: BaseTool) -> dict: 458 | """Get the output schema.""" 459 | try: 460 | if isinstance(tool, StructuredTool): 461 | if hasattr(tool, "coroutine") and tool.coroutine is not None: 462 | hints = get_type_hints(tool.coroutine) 463 | elif hasattr(tool, "func") and tool.func is not None: 464 | hints = get_type_hints(tool.func) 465 | else: 466 | raise ValueError(f"Invalid tool definition {tool}") 467 | elif isinstance(tool, BaseTool): 468 | hints = get_type_hints(tool._run) 469 | else: 470 | raise ValueError( 471 | f"Invalid tool definition {tool}. Expected a tool that was created " 472 | f"using the @tool decorator or an instance of StructuredTool or BaseTool" 473 | ) 474 | 475 | if "return" not in hints: 476 | return {} # Any type 477 | 478 | return_type = TypeAdapter(hints["return"]) 479 | json_schema = return_type.json_schema() 480 | return json_schema 481 | except Exception as e: 482 | logger.aerror(f"Error getting output schema: {e} for tool {tool}") 483 | # Generate a schema for any type 484 | return {} 485 | 486 | 487 | async def validation_exception_handler( 488 | request: Request, exc: RequestValidationError 489 | ) -> JSONResponse: 490 | """Exception translation for validation errors. 491 | 492 | This will match the shape of the error response to the one implemented by 493 | the tool calling spec. 494 | """ 495 | msg = ", ".join(str(e) for e in exc.errors()) 496 | if exc.body: 497 | msg = f"{exc.body}: {msg}" 498 | return JSONResponse( 499 | status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, 500 | content=jsonable_encoder({"message": msg}), 501 | ) 502 | --------------------------------------------------------------------------------