├── docs ├── _static │ └── .gitkeep ├── images │ └── langchain-mcp-tools-diagram.png ├── modules │ └── langchain_mcp_tools.rst ├── _templates │ └── layout.html ├── Makefile ├── index.rst ├── make.bat ├── README.md ├── conf.py └── langchain_mcp_tools_docs.py ├── .python-version ├── src └── langchain_mcp_tools │ ├── py.typed │ ├── __init__.py │ ├── tool_adapter.py │ ├── transport_utils.py │ └── langchain_mcp_tools.py ├── tests ├── __init__.py └── test_langchain_mcp_tools.py ├── .gitignore ├── .github └── workflows │ └── dependents.yml ├── .env.template ├── LICENSE ├── pyproject.toml ├── Makefile ├── testfiles ├── streamable_http_stateless_test_server.py ├── streamable_http_stateless_test_client.py ├── streamable_http_bearer_auth_test_client.py ├── remote_server_utils.py ├── streamable_http_bearer_auth_test_server.py ├── sse_auth_test_client.py ├── simple_usage.py ├── sse_auth_test_server.py ├── streamable_http_oauth_test_client.py └── streamable_http_oauth_test_server.py ├── README_DEV.md ├── CHANGELOG.md ├── TECHNICAL.md └── README.md /docs/_static/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- 1 | 3.11 2 | -------------------------------------------------------------------------------- /src/langchain_mcp_tools/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | # Test package initialization 3 | -------------------------------------------------------------------------------- /docs/images/langchain-mcp-tools-diagram.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hideya/langchain-mcp-tools-py/HEAD/docs/images/langchain-mcp-tools-diagram.png -------------------------------------------------------------------------------- /docs/modules/langchain_mcp_tools.rst: -------------------------------------------------------------------------------- 1 | langchain_mcp_tools 2 | =================== 3 | 4 | .. automodule:: langchain_mcp_tools_docs 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | .venv 3 | __pycache__/ 4 | .mypy_cache 5 | .pytest_cache 6 | /dist 7 | /build 8 | *egg-info 9 | *.log 10 | .DS_Store 11 | 12 | # Sphinx documentation 13 | docs/_build/ 14 | docs/modules/__pycache__/ 15 | docs/*.pyc 16 | 17 | # Test token file 18 | .test_token 19 | -------------------------------------------------------------------------------- /.github/workflows/dependents.yml: -------------------------------------------------------------------------------- 1 | name: Dependents Action 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | schedule: 7 | - cron: "0 0 1 * *" 8 | workflow_dispatch: 9 | 10 | jobs: 11 | dependents: 12 | runs-on: ubuntu-latest 13 | permissions: 14 | id-token: write 15 | steps: 16 | - uses: gouravkhunger/dependents.info@main 17 | -------------------------------------------------------------------------------- /docs/_templates/layout.html: -------------------------------------------------------------------------------- 1 | {% extends "!layout.html" %} 2 | 3 | {% block document %} 4 | 5 |
6 | 7 | Version {{ release }} 8 | 9 |
10 | 11 | {{ super() }} 12 | {% endblock %} 13 | -------------------------------------------------------------------------------- /src/langchain_mcp_tools/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | """LangChain MCP Tools - Convert MCP servers to LangChain tools.""" 3 | 4 | try: 5 | from importlib.metadata import version 6 | __version__ = version("langchain_mcp_tools") 7 | except ImportError: 8 | __version__ = "unknown" 9 | 10 | from .langchain_mcp_tools import ( 11 | convert_mcp_to_langchain_tools, 12 | McpServerCleanupFn, 13 | McpServersConfig, 14 | McpServerCommandBasedConfig, 15 | McpServerUrlBasedConfig, 16 | SingleMcpServerConfig, 17 | ) 18 | 19 | from .transport_utils import ( 20 | McpInitializationError, 21 | ) 22 | -------------------------------------------------------------------------------- /.env.template: -------------------------------------------------------------------------------- 1 | # PYPI_API_KEY will be read by Makefile 2 | PYPI_API_KEY=pypi-... 3 | 4 | # https://console.anthropic.com/settings/keys 5 | ANTHROPIC_API_KEY=sk-ant-... 6 | # https://platform.openai.com/api-keys 7 | OPENAI_API_KEY=sk-proj-... 8 | # https://aistudio.google.com/apikey 9 | GOOGLE_API_KEY=AI... 10 | # https://console.x.ai 11 | XAI_API_KEY=xai-... 12 | # https://console.groq.com/keys 13 | GROQ_API_KEY=gsk_... 14 | # https://cloud.cerebras.ai 15 | CEREBRAS_API_KEY=csk-... 16 | 17 | # BRAVE_API_KEY=BSA... 18 | # GITHUB_PERSONAL_ACCESS_TOKEN=github_pat_... 19 | # NOTION_INTEGRATION_SECRET=ntn_... 20 | # AIRTABLE_API_KEY=pat... 21 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. langchain-mcp-tools-py documentation master file 2 | 3 | langchain-mcp-tools-py 4 | ====================== 5 | 6 | A Python library to connect LangChain tools with MCP (Model Context Protocol) servers. 7 | 8 | Main Features 9 | ------------- 10 | 11 | * Convert MCP servers to LangChain tools 12 | * Support for command-based and URL-based MCP servers 13 | * Clean management of server lifecycles 14 | * Robust error handling and authentication pre-validation 15 | * Support for multiple transport types (stdio, HTTP, WebSocket) 16 | 17 | .. toctree:: 18 | :maxdepth: 2 19 | :caption: Contents: 20 | 21 | modules/langchain_mcp_tools 22 | 23 | Indices and tables 24 | ================== 25 | 26 | * :ref:`genindex` 27 | * :ref:`modindex` 28 | * :ref:`search` 29 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=. 11 | set BUILDDIR=_build 12 | 13 | %SPHINXBUILD% >NUL 2>NUL 14 | if errorlevel 9009 ( 15 | echo. 16 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 17 | echo.installed, then set the SPHINXBUILD environment variable to point 18 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 19 | echo.may add the Sphinx directory to PATH. 20 | echo. 21 | echo.If you don't have Sphinx installed, grab it from 22 | echo.https://www.sphinx-doc.org/ 23 | exit /b 1 24 | ) 25 | 26 | if "%1" == "" goto help 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 hideya kawahara 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 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "langchain-mcp-tools" 3 | version = "0.2.13" 4 | description = "Model Context Protocol (MCP) To LangChain Tools Conversion Utility" 5 | keywords = [ 6 | "modelcontextprotocol", 7 | "mcp", 8 | "mcp-client", 9 | "langchain", 10 | "langchain-python", 11 | "tool-call", 12 | "tool-calling", 13 | "python", 14 | ] 15 | readme = "README.md" 16 | requires-python = ">=3.11" 17 | dependencies = [ 18 | "jsonschema-pydantic>=0.6", 19 | "langchain>=0.3.26", 20 | "langchain-core>=0.3.66", 21 | "mcp>=1.9.4", 22 | ] 23 | 24 | [project.optional-dependencies] 25 | dev = [ 26 | "dotenv>=0.9.9", 27 | "fastapi>=0.115.12", 28 | "fastmcp>=2.10.1", 29 | "pyjwt>=2.10.1", 30 | "langchain-anthropic>=0.3.17", 31 | "langchain-cerebras>=0.5.0", 32 | "langchain-google-genai>=2.1.5", 33 | "langchain-groq>=0.3.7", 34 | "langchain-openai>=0.3.0", 35 | "langchain-xai>=0.2.4", 36 | "langgraph>=0.2.62", 37 | "pytest>=8.3.4", 38 | "pytest-asyncio>=0.25.2", 39 | "websockets>=15.0.1", 40 | ] 41 | 42 | [tool.setuptools] 43 | package-dir = {"" = "src"} 44 | 45 | [tool.setuptools.packages.find] 46 | where = ["src"] 47 | 48 | [tool.setuptools.package-data] 49 | langchain_mcp_tools = ["py.typed"] 50 | 51 | [project.urls] 52 | "Bug Tracker" = "https://github.com/hideya/langchain-mcp-tools-py/issues" 53 | "Source Code" = "https://github.com/hideya/langchain-mcp-tools-py" 54 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # NOTES: 2 | # - The command lines (recipe lines) must start with a TAB character. 3 | # - Each command line runs in a separate shell without .ONESHELL: 4 | .PHONY: clean install build check-pkg prep-publish test-publish publish \ 5 | test run-example sphinx gh-pages 6 | .ONESHELL: 7 | 8 | .venv: 9 | uv venv 10 | 11 | install: .venv 12 | uv pip install -e . 13 | 14 | build: clean install 15 | uv build 16 | @echo 17 | uvx twine check dist/* 18 | 19 | check-pkg: 20 | uv pip show -f langchain-mcp-tools 21 | 22 | prep-publish: build 23 | # set PYPI_API_KEY from .env 24 | $(eval export $(shell grep '^PYPI_API_KEY=' .env )) 25 | 26 | # check if PYPI_API_KEY is set 27 | @if [ -z "$$PYPI_API_KEY" ]; then \ 28 | echo "Error: PYPI_API_KEY environment variable is not set"; \ 29 | exit 1; \ 30 | fi 31 | 32 | publish: prep-publish 33 | uvx twine upload \ 34 | --verbose \ 35 | --repository-url https://upload.pypi.org/legacy/ dist/* \ 36 | --password ${PYPI_API_KEY} 37 | 38 | test-publish: prep-publish 39 | tar tzf dist/*.tar.gz 40 | @echo 41 | unzip -l dist/*.whl 42 | @echo 43 | uvx twine check dist/* 44 | 45 | install-dev: install 46 | uv pip install -e ".[dev]" 47 | 48 | test: install-dev 49 | .venv/bin/pytest tests/ -v 50 | 51 | run-simple-usage: install-dev 52 | uv run testfiles/simple_usage.py 53 | 54 | # E.g.: make run-streamable-http-oauth-test-server 55 | run-%-test-server: install-dev 56 | uv run testfiles/$(shell echo $* | tr '-' '_')_test_server.py 57 | 58 | # E.g.: make run-streamable-http-oauth-test-client 59 | run-%-test-client: install-dev 60 | uv run testfiles/$(shell echo $* | tr '-' '_')_test_client.py 61 | 62 | sphinx: install 63 | make -C docs clean html 64 | 65 | deploy-docs: sphinx 66 | ghp-import -n -p -f docs/_build/html 67 | 68 | clean: 69 | git clean -fdxn -e .env 70 | @read -p '\nOK? ' 71 | git clean -fdx -e .env 72 | -------------------------------------------------------------------------------- /testfiles/streamable_http_stateless_test_server.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | FastMCP Bearer Token Authentication Test Server 4 | Using FastMCP 2.0's built-in BearerAuthProvider - this is the best practice! 5 | """ 6 | 7 | from fastmcp import FastMCP 8 | from fastmcp.server.auth import BearerAuthProvider, RSAKeyPair 9 | 10 | # For testing, generate a key pair (use external OAuth in production) 11 | key_pair = RSAKeyPair.generate() 12 | 13 | # Create the auth provider 14 | auth = BearerAuthProvider( 15 | public_key=key_pair.public_key_pem, 16 | # Optional: additional validation 17 | issuer="test-auth-server", 18 | audience="mcp-test-client" 19 | ) 20 | 21 | # Create FastMCP server with auth 22 | mcp = FastMCP( 23 | name="BearerAuthTestServer", 24 | auth=auth # 👈 Built-in auth support! 25 | ) 26 | 27 | @mcp.tool(description="Echo back text with user information") 28 | def authenticated_echo(message: str) -> str: 29 | """Echo back a message with authentication context.""" 30 | return f"Authenticated echo: {message}" 31 | 32 | @mcp.tool(description="Get current user information") 33 | def get_user_info() -> str: 34 | """Get information about the authenticated user.""" 35 | return "User info: authenticated user (token validation successful)" 36 | 37 | @mcp.tool(description="Add two numbers (requires authentication)") 38 | def secure_add(a: float, b: float) -> float: 39 | """Add two numbers securely.""" 40 | return a + b 41 | 42 | @mcp.resource("user://profile") 43 | def get_user_profile() -> str: 44 | """Get user profile information.""" 45 | return "User profile: authenticated user profile data" 46 | 47 | if __name__ == "__main__": 48 | print("🚀 Starting FastMCP Bearer Token Authentication Test Server") 49 | print("🔐 Authentication: Built-in BearerAuthProvider") 50 | print("🔗 Endpoint: http://localhost:8001/mcp") 51 | print("🛠️ Tools available: authenticated_echo, get_user_info, secure_add") 52 | print("📦 Resources available: user://profile") 53 | print("-" * 70) 54 | 55 | # Generate test token for testing 56 | test_token = key_pair.create_token( 57 | issuer="test-auth-server", 58 | audience="mcp-test-client", 59 | subject="test-user", 60 | extra_claims={"scope": "read write"} 61 | ) 62 | print(f"🔑 Test token: {test_token}") 63 | print("-" * 70) 64 | print("🧪 Test command:") 65 | print(f' curl -H "Authorization: Bearer {test_token}" http://localhost:8001/mcp') 66 | print("-" * 70) 67 | print("💡 Use Ctrl+C to stop the server") 68 | 69 | # Run with Streamable HTTP and authentication 70 | mcp.run( 71 | transport="http", # Streamable HTTP 72 | host="127.0.0.1", 73 | port=8001, 74 | path="/mcp" 75 | ) 76 | -------------------------------------------------------------------------------- /testfiles/streamable_http_stateless_test_client.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Test Client for Streamable HTTP MCP Servers 4 | 5 | This script tests your langchain-mcp-tools library against the test servers. 6 | It demonstrates both single server and multi-server configurations. 7 | 8 | Usage: 9 | # First start a test server in another terminal: 10 | uv run testfiles/streamable_http_stateless_test_server.py 11 | 12 | # Then run this test client: 13 | uv run testfiles/streamable_http_stateless_test_client.py 14 | """ 15 | 16 | import asyncio 17 | import logging 18 | from langchain_mcp_tools import convert_mcp_to_langchain_tools 19 | 20 | # Configure logging to see the transport detection in action 21 | logging.basicConfig(level=logging.INFO) 22 | 23 | async def test_simple_server(): 24 | """Test the simple stateless server.""" 25 | print("🧪 Testing Simple Stateless Server") 26 | print("=" * 50) 27 | 28 | server_config = { 29 | "test-server": { 30 | "url": "http://127.0.0.1:8000/mcp", 31 | # No transport specified - should auto-detect Streamable HTTP 32 | "timeout": 10.0 33 | } 34 | } 35 | 36 | try: 37 | tools, cleanup = await convert_mcp_to_langchain_tools(server_config) 38 | print(f"✅ Connected to simple server with {len(tools)} tools") 39 | 40 | # List available tools 41 | print("\n🛠️ Available Tools:") 42 | for tool in tools: 43 | print(f" • {tool.name}: {tool.description}") 44 | 45 | # Test a few tools 46 | if tools: 47 | print("\n🔍 Testing Tools:") 48 | 49 | # Test add tool 50 | add_tool = next((t for t in tools if t.name == "add"), None) 51 | if add_tool: 52 | result = await add_tool.ainvoke({"a": 5, "b": 3}) 53 | print(f" add(5, 3) = {result}") 54 | 55 | # Test greet tool 56 | greet_tool = next((t for t in tools if t.name == "greet"), None) 57 | if greet_tool: 58 | result = await greet_tool.ainvoke({"name": "World"}) 59 | print(f" greet('World') = {result}") 60 | 61 | # Test echo tool 62 | echo_tool = next((t for t in tools if t.name == "echo"), None) 63 | if echo_tool: 64 | result = await echo_tool.ainvoke({"message": "Hello MCP!"}) 65 | print(f" echo('Hello MCP!') = {result}") 66 | 67 | await cleanup() 68 | print("\n\n✅ Simple server test completed successfully\n") 69 | 70 | except Exception as e: 71 | print(f"\n\n❌ Error testing simple server: {e}\n") 72 | 73 | 74 | async def main(): 75 | """Run all tests.""" 76 | print("🚀 Testing langchain-mcp-tools with Streamable HTTP") 77 | print("=" * 70) 78 | print("Make sure you have started the test servers first:") 79 | print(" • uv run streamable_http_stateless_test_server.py") 80 | print("=" * 70) 81 | 82 | # Test simple server 83 | await test_simple_server() 84 | 85 | print("\n🎉 All tests completed!") 86 | print("\n💡 Notes:") 87 | print(" • Transport auto-detection should show 'Streamable HTTP' in logs") 88 | print(" • No fallback to SSE should occur with these test servers") 89 | print(" • All servers are stateless (no session persistence)") 90 | 91 | if __name__ == "__main__": 92 | asyncio.run(main()) 93 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # Documentation for langchain-mcp-tools-py 2 | 3 | This directory contains the Sphinx documentation setup for the `langchain-mcp-tools-py` project. 4 | 5 | ## Special Setup Notes 6 | 7 | ### Documentation-Only Module 8 | 9 | The documentation uses a simplified version of the module (`langchain_mcp_tools_docs.py`) instead of the actual implementation. This approach was chosen to avoid issues with: 10 | 11 | 1. Complex import dependencies 12 | 2. Type annotations using the pipe operator (`|`) which can cause problems with Sphinx 13 | 3. Module-level code that might exit the process during documentation build 14 | 15 | The file `langchain_mcp_tools_docs.py` contains simplified versions of the classes and functions with properly formatted docstrings but without the actual implementation details. 16 | 17 | ### When to Update the Documentation-Only Module 18 | 19 | You should update `langchain_mcp_tools_docs.py` when: 20 | 21 | 1. You add new public functions or classes to the actual module 22 | 2. You change function/method signatures (parameters or return types) 23 | 3. You modify docstrings in the original code 24 | 4. You make any changes to types or classes that are exposed in the API 25 | 26 | ### How to Update the Documentation-Only Module 27 | 28 | 1. Copy the updated docstrings from the main module 29 | 2. Keep only the type definitions and function signatures (without implementation) 30 | 3. Make sure all docstrings follow Google style formatting 31 | 4. Run `make html` to verify that the documentation builds without warnings 32 | 33 | ## Building the Documentation 34 | 35 | To build the documentation: 36 | 37 | ```bash 38 | # Navigate to the docs directory 39 | cd docs 40 | 41 | # Build HTML documentation 42 | make html 43 | 44 | # View the documentation 45 | open _build/html/index.html 46 | ``` 47 | 48 | ## Documentation Structure 49 | 50 | - `conf.py`: Sphinx configuration file 51 | - `index.rst`: Main entry point for the documentation 52 | - `modules/`: Directory containing module-specific documentation 53 | - `langchain_mcp_tools.rst`: Documentation for the main module 54 | - `langchain_mcp_tools_docs.py`: Documentation-only version of the module 55 | - `_build/`: Generated documentation (not committed to the repository) 56 | 57 | ## Tips for Docstrings 58 | 59 | When writing docstrings, follow these guidelines: 60 | 61 | 1. Use Google style format (supported by Napoleon extension) 62 | 2. Add blank lines after section headers (Args:, Returns:, etc.) 63 | 3. Use asterisks (*) for bullet points 64 | 4. Add proper indentation for code examples 65 | 5. Add blank lines before and after code blocks 66 | 6. End all sentences with a period 67 | 68 | Example of a well-formatted docstring: 69 | 70 | ```python 71 | def example_function(arg1: str, arg2: int = 0) -> bool: 72 | """Short description of the function. 73 | 74 | More detailed explanation of what the function does and how to use it. 75 | 76 | Args: 77 | arg1: Description of the first argument 78 | arg2: Description of the second argument, defaults to 0 79 | 80 | Returns: 81 | Description of the return value 82 | 83 | Raises: 84 | ValueError: When the function encounters an invalid input 85 | 86 | Example: 87 | 88 | result = example_function("test", 42) 89 | 90 | # Use the result 91 | print(result) 92 | """ 93 | # Implementation 94 | pass 95 | ``` 96 | 97 | ## Theme Customization 98 | 99 | The documentation uses the default Alabaster theme. To customize the theme, 100 | edit the `html_theme` and related settings in `conf.py`. 101 | -------------------------------------------------------------------------------- /README_DEV.md: -------------------------------------------------------------------------------- 1 | # Making Changes to langchain-mcp-tools-py 2 | 3 | Thank you for your interest in langchain-mcp-tools-py! 4 | This guide is focused on the technical aspects of making changes to this project. 5 | 6 | ## Development Environment Setup 7 | 8 | ### Prerequisites 9 | 10 | - Python 3.11 or higher 11 | - [uv](https://github.com/astral-sh/uv) - A fast Python package installer and resolver 12 | - `make` - The project uses `Makefile` to simplify the development workflow 13 | - git 14 | 15 | ### Setting Up Your Environment 16 | 17 | The following will create and activate a virtual environment using `uv`, if not already done, 18 | and install the package using `uv pip install -e . ` 19 | including additional dependencies needed to develop and test. 20 | 21 | ```bash 22 | make install 23 | ``` 24 | 25 | ## Project Architecture Overview 26 | 27 | The project follows a simple and focused architecture: 28 | 29 | - **Core functionality**: The main module `langchain_mcp_tools.py` contains the functionality to convert MCP server tools into LangChain tools. 30 | 31 | - **Key components**: 32 | - `convert_mcp_to_langchain_tools`: The main entry point that handles parallel initialization of MCP servers and tool conversion 33 | - `spawn_mcp_server_and_get_transport`: Handles different types of MCP server initialization (stdio, SSE, WebSocket) 34 | - `get_mcp_server_tools`: Converts MCP tools to LangChain format using a custom adapter class 35 | 36 | - **Data flow**: 37 | 1. MCP server configurations are provided 38 | 2. Servers are initialized in parallel 39 | 3. Available tools are retrieved from each server 40 | 4. Tools are converted to LangChain format 41 | 5. A cleanup function is returned to handle resource management 42 | 43 | ## Understanding the Implementation 44 | 45 | For detailed technical analysis, design decisions, and known issues, see [TECHNICAL.md](TECHNICAL.md). 46 | 47 | ## Development Workflow 48 | 49 | 1. **Making changes** 50 | 51 | When making changes, keep the following in mind: 52 | - Maintain type hints for all functions and classes 53 | - Follow the existing code style (the project uses standard Python formatting) 54 | - Add comments for complex logic 55 | 56 | 2. **Test changes quickly** 57 | 58 | The project includes a simple usage example that can be used to test changes: 59 | 60 | ```bash 61 | make run-simple-usage 62 | ``` 63 | 64 | 3. **Running tests** 65 | 66 | The tests are still in a preliminary stage, but it may help to identify possible bugs. 67 | Please try the following to run the (very small) test suite with pytest. 68 | 69 | ```bash 70 | make test 71 | ``` 72 | 73 | 4. **Clean build artifacts** 74 | 75 | Try clean-build once you feel comfortable with your changes. 76 | 77 | The following will remove all files that aren't git-controlled, except `.env'. 78 | That means it will remove files you've created that aren't checked in, 79 | the virtual environment, and all cache files. 80 | 81 | ```bash 82 | make clean 83 | ``` 84 | 85 | 5. **Building the package** 86 | 87 | The following will build the package and verify it's correctly structured. 88 | Note that this will also perform `make clean` against the repository, 89 | i.e. remove all files other than those controlled by git and the `.env` file. 90 | 91 | ```bash 92 | make build 93 | ``` 94 | 95 | This will: 96 | 1. Clean previous build artifacts 97 | 2. Install the package in development mode 98 | 3. Build the distribution packages (wheel and tarball) 99 | 4. Check the built packages with twine 100 | 101 | --- 102 | 103 | If you have any questions about development that aren't covered here, please open an issue for discussion. 104 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # At the top of your conf.py file 2 | import os 3 | import sys 4 | from unittest.mock import MagicMock 5 | 6 | # Add both source directory and docs directory to path 7 | sys.path.insert(0, os.path.abspath('../src')) 8 | sys.path.insert(0, os.path.abspath('.')) 9 | 10 | # Try to get version from package 11 | try: 12 | from langchain_mcp_tools import __version__ 13 | version = __version__ 14 | release = __version__ 15 | except ImportError: 16 | # Fallback to manual version if import fails during doc build 17 | version = 'unknown' 18 | release = 'unknown' 19 | 20 | # Set environment variable for documentation build 21 | os.environ['SPHINX_BUILD'] = 'True' 22 | 23 | # Mock all problematic imports 24 | autodoc_mock_imports = [ 25 | 'anyio', 'jsonschema_pydantic', 'langchain_core', 26 | 'mcp', 'pydantic', 'mcp.types' 27 | ] 28 | 29 | # Configuration file for the Sphinx documentation builder. 30 | # 31 | # For the full list of built-in configuration values, see the documentation: 32 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 33 | 34 | # -- Project information ----------------------------------------------------- 35 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information 36 | 37 | project = 'langchain-mcp-tools-py' 38 | copyright = '2025, hideya' 39 | author = 'hideya' 40 | 41 | # Version info (dynamically loaded from package above) 42 | # version and release are set from package import or fallback 43 | 44 | # -- General configuration --------------------------------------------------- 45 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration 46 | 47 | # Add to your extensions list 48 | extensions = [ 49 | 'sphinx.ext.autodoc', 50 | 'sphinx.ext.napoleon', 51 | 'sphinx.ext.viewcode', 52 | 'sphinx_autodoc_typehints', 53 | ] 54 | 55 | # Configure Napoleon for Google-style docstrings 56 | napoleon_google_docstring = True 57 | napoleon_numpy_docstring = False 58 | napoleon_include_init_with_doc = True 59 | napoleon_include_private_with_doc = False 60 | napoleon_include_special_with_doc = True 61 | napoleon_use_admonition_for_examples = True 62 | napoleon_use_admonition_for_notes = True 63 | napoleon_use_admonition_for_references = False 64 | 65 | # Configure autodoc 66 | autodoc_default_options = { 67 | 'members': True, 68 | 'member-order': 'bysource', 69 | 'special-members': '__init__', 70 | 'undoc-members': True, 71 | 'exclude-members': '__weakref__' 72 | } 73 | 74 | templates_path = ['_templates'] 75 | exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] 76 | 77 | # -- Options for HTML output ------------------------------------------------- 78 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output 79 | 80 | html_theme = 'alabaster' 81 | html_static_path = ['_static'] 82 | 83 | # Alabaster theme options (to show version prominently) 84 | html_theme_options = { 85 | 'description': f'MCP Tools for LangChain v{release}', 86 | 'github_user': 'hideya', # Replace with your GitHub username if public 87 | 'github_repo': 'langchain-mcp-tools-py', # Replace with your repo name if public 88 | 'show_powered_by': False, 89 | 'sidebar_width': '230px', 90 | 'page_width': '1024px', 91 | 'fixed_sidebar': True, 92 | } 93 | 94 | # Show version in the HTML title 95 | html_title = f'{project} v{release} Documentation' 96 | 97 | # Show version info in sidebar 98 | html_short_title = f'{project} v{release}' 99 | 100 | # Add version info to the page footer 101 | html_last_updated_fmt = '%b %d, %Y' 102 | html_show_sphinx = False 103 | 104 | # Enable RST substitutions for version info 105 | rst_epilog = f""" 106 | .. |release| replace:: {release} 107 | .. |version| replace:: {version} 108 | """ 109 | -------------------------------------------------------------------------------- /testfiles/streamable_http_bearer_auth_test_client.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Test Client with Auto Token Loading 4 | """ 5 | 6 | import asyncio 7 | import logging 8 | import time 9 | from pathlib import Path 10 | from langchain_mcp_tools import convert_mcp_to_langchain_tools 11 | 12 | # Configure logging 13 | logging.basicConfig(level=logging.INFO) 14 | 15 | TOKEN_FILE = Path(".test_token") 16 | 17 | def load_test_token() -> str | None: 18 | """Load test token from file with validation.""" 19 | try: 20 | if not TOKEN_FILE.exists(): 21 | print(f"❌ Token file {TOKEN_FILE} not found.") 22 | print(" Make sure the test server is running!") 23 | return None 24 | 25 | # Check if file is recent (within last hour) 26 | file_age = time.time() - TOKEN_FILE.stat().st_mtime 27 | if file_age > 3600: # 1 hour 28 | print(f"⚠️ Token file is {file_age/60:.1f} minutes old, might be expired") 29 | 30 | token = TOKEN_FILE.read_text().strip() 31 | if not token: 32 | print("❌ Token file is empty") 33 | return None 34 | 35 | print(f"✅ Loaded token from {TOKEN_FILE} (age: {file_age/60:.1f} minutes)") 36 | return token 37 | 38 | except Exception as e: 39 | print(f"❌ Failed to load token: {e}") 40 | return None 41 | 42 | def wait_for_token_file(timeout: int = 30) -> str | None: 43 | """Wait for token file to appear (useful if client starts before server).""" 44 | print(f"⏳ Waiting for token file {TOKEN_FILE} (timeout: {timeout}s)...") 45 | 46 | start_time = time.time() 47 | while time.time() - start_time < timeout: 48 | token = load_test_token() 49 | if token: 50 | return token 51 | time.sleep(1) 52 | 53 | print(f"⏰ Timeout waiting for token file") 54 | return None 55 | 56 | async def test_valid_authentication(token: str): 57 | """Test valid JWT token authentication.""" 58 | print("✅ Test 1: Valid JWT Token") 59 | print("=" * 50) 60 | 61 | server_config = { 62 | "auth-server": { 63 | "url": "http://127.0.0.1:8001/mcp", 64 | "headers": {"Authorization": f"Bearer {token}"}, 65 | "timeout": 10.0 66 | } 67 | } 68 | 69 | try: 70 | tools, cleanup = await convert_mcp_to_langchain_tools(server_config) 71 | print(f"✅ Connected to auth server with {len(tools)} tools") 72 | 73 | # List available tools 74 | print("\n🛠️ Available Tools:") 75 | for tool in tools: 76 | print(f" • {tool.name}: {tool.description}") 77 | 78 | # Test a few tools 79 | if tools: 80 | print("\n🔍 Testing Authenticated Tools:") 81 | 82 | # Test authenticated_echo tool 83 | echo_tool = next((t for t in tools if t.name == "authenticated_echo"), None) 84 | if echo_tool: 85 | result = await echo_tool.ainvoke({"message": "Hello Auto-Token!"}) 86 | print(f" authenticated_echo('Hello Auto-Token!') = {result}") 87 | 88 | # Test secure_add tool 89 | add_tool = next((t for t in tools if t.name == "secure_add"), None) 90 | if add_tool: 91 | result = await add_tool.ainvoke({"a": 42, "b": 8}) 92 | print(f" secure_add(42, 8) = {result}") 93 | 94 | await cleanup() 95 | print("✅ Valid auth test completed successfully") 96 | return True 97 | 98 | except Exception as e: 99 | print(f"❌ Valid auth test failed: {e}") 100 | return False 101 | 102 | async def main(): 103 | """Run authentication tests with auto token loading.""" 104 | print("🧪 Testing langchain-mcp-tools with Auto Token Loading") 105 | print("=" * 70) 106 | 107 | # Try to load token, wait if necessary 108 | token = load_test_token() 109 | if not token: 110 | print("🔄 Token not found, waiting for server to start...") 111 | token = wait_for_token_file() 112 | 113 | if not token: 114 | print("\n❌ Could not load test token!") 115 | print("Please ensure the test server is running:") 116 | print(" uv run testfiles/streamable_http_bearer_auth_test_server.py") 117 | return 118 | 119 | print(f"🔑 Using token: {token[:50]}...") 120 | print("=" * 70) 121 | 122 | # Run tests 123 | success = await test_valid_authentication(token) 124 | 125 | if success: 126 | print("\n🎉 All tests completed successfully!") 127 | else: 128 | print("\n❌ Some tests failed!") 129 | 130 | if __name__ == "__main__": 131 | asyncio.run(main()) 132 | -------------------------------------------------------------------------------- /testfiles/remote_server_utils.py: -------------------------------------------------------------------------------- 1 | """ 2 | Utility functions for starting and managing remote MCP servers for testing. 3 | """ 4 | 5 | import socket 6 | import subprocess 7 | import select 8 | import time 9 | 10 | 11 | # NOTE: Hard-coded dependency on the Supergateway message 12 | # to easily identify the end of initialization. 13 | SUPERGATEWAY_READY_MESSAGE = "[supergateway] Listening on port" 14 | 15 | 16 | def find_free_port(): 17 | """Find and return a free port on localhost.""" 18 | with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: 19 | s.bind(("", 0)) # Bind to a free port provided by the system 20 | return s.getsockname()[1] # Return the port number assigned 21 | 22 | 23 | def start_remote_mcp_server_locally( 24 | transport_type, mcp_server_run_command, timeout=30): 25 | """ 26 | Start an MCP server process via supergateway with the specified transport 27 | type. Supergateway runs MCP stdio-based servers over SSE or WebSockets 28 | and is used here to run local SSE/WS servers for connection testing. 29 | Ref: https://github.com/supercorp-ai/supergateway 30 | 31 | Args: 32 | transport_type (str): The transport type, either 'sse' or 'ws' 33 | mcp_server_run_command (str): The command to run the MCP server 34 | timeout (int): Maximum time to wait for server startup in seconds 35 | 36 | Returns: 37 | tuple: (server_process, server_port) 38 | 39 | Raises: 40 | TimeoutError: If the server doesn't start within the timeout period 41 | ValueError: If the transport type is not supported 42 | """ 43 | server_port = find_free_port() 44 | 45 | # Base command common to both server types 46 | command = [ 47 | "npx", 48 | "-y", 49 | "supergateway", 50 | "--stdio", 51 | mcp_server_run_command, 52 | "--port", str(server_port), 53 | ] 54 | 55 | # Add transport-specific arguments 56 | if transport_type.lower() == 'sse': 57 | command.extend([ 58 | "--baseUrl", f"http://localhost:{server_port}", 59 | "--ssePath", "/sse", 60 | "--messagePath", "/message" 61 | ]) 62 | elif transport_type.lower() == 'ws': 63 | command.extend([ 64 | "--outputTransport", "ws", 65 | "--messagePath", "/message" 66 | ]) 67 | else: 68 | raise ValueError(f"Unsupported transport type: {transport_type}") 69 | 70 | # Start the server process with piped stdout/stderr 71 | print(f"Starting {transport_type.upper()} MCP Server Process...") 72 | server_process = subprocess.Popen( 73 | command, 74 | stdout=subprocess.PIPE, 75 | stderr=subprocess.PIPE, 76 | text=True, 77 | bufsize=1 # Line buffered 78 | ) 79 | 80 | # Wait for the server to start by watching for the specific log message 81 | start_time = time.time() 82 | ready_message = SUPERGATEWAY_READY_MESSAGE 83 | poll_obj = select.poll() 84 | poll_obj.register(server_process.stdout, select.POLLIN) 85 | 86 | while True: 87 | # Check if process is still running 88 | if server_process.poll() is not None: 89 | # Process exited 90 | returncode = server_process.poll() 91 | error_output = server_process.stderr.read() 92 | raise RuntimeError( 93 | f"Server process exited with code {returncode} " 94 | f"before starting: {error_output}" 95 | ) 96 | 97 | # Check for timeout 98 | if time.time() - start_time > timeout: 99 | server_process.terminate() 100 | raise TimeoutError( 101 | f"Timed out waiting for {transport_type.upper()} " 102 | f"server to start after {timeout} seconds" 103 | ) 104 | 105 | # Check for output 106 | if poll_obj.poll(100): # 100ms timeout 107 | line = server_process.stdout.readline().strip() 108 | print(line) # Echo to console 109 | 110 | if ready_message in line: 111 | print(f"{transport_type.upper()} MCP Server is ready " 112 | f"on port {server_port}") 113 | break 114 | 115 | # Start a thread to continue reading and printing output 116 | def _monitor_output(process): 117 | import threading 118 | 119 | def _reader(stream, is_error): 120 | # prefix = "ERROR: " if is_error else "" 121 | prefix = "" 122 | for line in stream: 123 | print(f"{prefix}{line.strip()}") 124 | 125 | threading.Thread( 126 | target=_reader, 127 | args=(process.stdout, False), 128 | daemon=True 129 | ).start() 130 | 131 | threading.Thread( 132 | target=_reader, 133 | args=(process.stderr, True), 134 | daemon=True 135 | ).start() 136 | 137 | # Start monitoring the output in the background 138 | _monitor_output(server_process) 139 | 140 | return server_process, server_port 141 | -------------------------------------------------------------------------------- /testfiles/streamable_http_bearer_auth_test_server.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | FastMCP Bearer Token Authentication Test Server with Auto-Cleanup 4 | """ 5 | 6 | import atexit 7 | import signal 8 | import sys 9 | import os 10 | from pathlib import Path 11 | 12 | from fastmcp import FastMCP 13 | from fastmcp.server.auth import BearerAuthProvider 14 | from fastmcp.server.auth.providers.bearer import RSAKeyPair 15 | 16 | # Token file path 17 | TOKEN_FILE = Path(".test_token") 18 | 19 | def cleanup_token_file(): 20 | """Remove the token file if it exists.""" 21 | try: 22 | if TOKEN_FILE.exists(): 23 | TOKEN_FILE.unlink() 24 | print("🧹 Cleaned up token file") 25 | except Exception as e: 26 | print(f"⚠️ Warning: Could not clean up token file: {e}") 27 | 28 | def setup_cleanup_handlers(): 29 | """Set up cleanup handlers for various termination scenarios.""" 30 | # Register cleanup for normal exit 31 | atexit.register(cleanup_token_file) 32 | 33 | # Register cleanup for SIGINT (Ctrl+C) and SIGTERM 34 | def signal_handler(signum, frame): 35 | print(f"\n🛑 Received signal {signum}, cleaning up...") 36 | cleanup_token_file() 37 | sys.exit(0) 38 | 39 | signal.signal(signal.SIGINT, signal_handler) # Ctrl+C 40 | signal.signal(signal.SIGTERM, signal_handler) # Termination signal 41 | 42 | # On Windows, also handle SIGBREAK (Ctrl+Break) 43 | if hasattr(signal, 'SIGBREAK'): 44 | signal.signal(signal.SIGBREAK, signal_handler) 45 | 46 | # For testing, generate a key pair 47 | key_pair = RSAKeyPair.generate() 48 | 49 | # Create the auth provider 50 | auth = BearerAuthProvider( 51 | public_key=key_pair.public_key, 52 | issuer="test-auth-server", 53 | audience="mcp-test-client" 54 | ) 55 | 56 | # Create FastMCP server with auth 57 | mcp = FastMCP( 58 | name="BearerAuthTestServer", 59 | auth=auth 60 | ) 61 | 62 | @mcp.tool(description="Echo back text with user information") 63 | def authenticated_echo(message: str) -> str: 64 | """Echo back a message with authentication context.""" 65 | return f"Authenticated echo: {message}" 66 | 67 | @mcp.tool(description="Get current user information") 68 | def get_user_info() -> str: 69 | """Get information about the authenticated user.""" 70 | return "User info: authenticated user (token validation successful)" 71 | 72 | @mcp.tool(description="Add two numbers (requires authentication)") 73 | def secure_add(a: float, b: float) -> float: 74 | """Add two numbers securely.""" 75 | return a + b 76 | 77 | @mcp.resource("user://profile") 78 | def get_user_profile() -> str: 79 | """Get user profile information.""" 80 | return "User profile: authenticated user profile data" 81 | 82 | if __name__ == "__main__": 83 | # Set up cleanup handlers FIRST 84 | setup_cleanup_handlers() 85 | 86 | print("🚀 Starting FastMCP Bearer Token Authentication Test Server") 87 | print("🔐 Authentication: Built-in BearerAuthProvider") 88 | print("🔗 Endpoint: http://localhost:8001/mcp") 89 | print("🛠️ Tools available: authenticated_echo, get_user_info, secure_add") 90 | print("📦 Resources available: user://profile") 91 | print("-" * 70) 92 | 93 | # Generate and save test token 94 | test_token = key_pair.create_token( 95 | issuer="test-auth-server", 96 | audience="mcp-test-client", 97 | subject="test-user" 98 | ) 99 | 100 | try: 101 | # Save token to file 102 | TOKEN_FILE.write_text(test_token) 103 | print(f"💾 Test token saved to {TOKEN_FILE}") 104 | 105 | # Add to .gitignore if it exists, or create it 106 | gitignore_path = Path(".gitignore") 107 | gitignore_content = "" 108 | if gitignore_path.exists(): 109 | gitignore_content = gitignore_path.read_text() 110 | 111 | if ".test_token" not in gitignore_content: 112 | with gitignore_path.open("a") as f: 113 | f.write("\n# Test token file\n.test_token\n") 114 | print("📝 Added .test_token to .gitignore") 115 | 116 | except Exception as e: 117 | print(f"❌ Failed to save token: {e}") 118 | cleanup_token_file() 119 | sys.exit(1) 120 | 121 | print(f"🔑 Test token: {test_token}") 122 | print("-" * 70) 123 | print("🧪 Test command:") 124 | print(f' curl -H "Authorization: Bearer {test_token}" http://localhost:8001/mcp') 125 | print("-" * 70) 126 | print("💡 Use Ctrl+C to stop the server (token will be auto-cleaned)") 127 | 128 | try: 129 | # Run with Streamable HTTP and authentication 130 | mcp.run( 131 | transport="http", 132 | host="127.0.0.1", 133 | port=8001, 134 | path="/mcp" 135 | ) 136 | except KeyboardInterrupt: 137 | print("\n🛑 Server stopped by user") 138 | except Exception as e: 139 | print(f"\n❌ Server error: {e}") 140 | finally: 141 | # Extra cleanup just in case 142 | cleanup_token_file() 143 | -------------------------------------------------------------------------------- /tests/test_langchain_mcp_tools.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from unittest.mock import AsyncMock, MagicMock, patch 3 | from langchain_core.tools import BaseTool 4 | from langchain_mcp_tools.langchain_mcp_tools import ( 5 | convert_mcp_to_langchain_tools, 6 | ) 7 | 8 | # Fix the asyncio mark warning by installing pytest-asyncio 9 | pytest_plugins = ('pytest_asyncio',) 10 | 11 | 12 | @pytest.fixture 13 | def mock_stdio_client(): 14 | with patch('langchain_mcp_tools.langchain_mcp_tools.stdio_client') as mock: 15 | mock.return_value.__aenter__.return_value = (AsyncMock(), AsyncMock()) 16 | yield mock 17 | 18 | 19 | @pytest.fixture 20 | def mock_client_session(): 21 | with patch('langchain_mcp_tools.langchain_mcp_tools.ClientSession') \ 22 | as mock: 23 | session = AsyncMock() 24 | # Mock the list_tools response 25 | session.list_tools.return_value = MagicMock( 26 | tools=[ 27 | MagicMock( 28 | name="tool1", 29 | description="Test tool", 30 | inputSchema={"type": "object", "properties": {}} 31 | ) 32 | ] 33 | ) 34 | mock.return_value.__aenter__.return_value = session 35 | yield mock 36 | 37 | 38 | @pytest.mark.asyncio 39 | async def test_convert_mcp_to_langchain_tools_empty(): 40 | server_configs = {} 41 | tools, cleanup = await convert_mcp_to_langchain_tools(server_configs) 42 | assert isinstance(tools, list) 43 | assert len(tools) == 0 44 | await cleanup() 45 | 46 | 47 | """ 48 | @pytest.mark.asyncio 49 | async def test_convert_mcp_to_langchain_tools_invalid_config(): 50 | server_configs = {"invalid": {"command": "nonexistent"}} 51 | with pytest.raises(Exception): 52 | await convert_mcp_to_langchain_tools(server_configs) 53 | """ 54 | 55 | 56 | """ 57 | @pytest.mark.asyncio 58 | async def test_convert_single_mcp_success( 59 | mock_stdio_client, 60 | mock_client_session 61 | ): 62 | # Test data 63 | server_name = "test_server" 64 | server_config = { 65 | "command": "test_command", 66 | "args": ["--test"], 67 | "env": {"TEST_ENV": "value"} 68 | } 69 | langchain_tools = [] 70 | ready_event = asyncio.Event() 71 | cleanup_event = asyncio.Event() 72 | 73 | # Create task 74 | task = asyncio.create_task( 75 | convert_single_mcp_to_langchain_tools( 76 | server_name, 77 | server_config, 78 | langchain_tools, 79 | ready_event, 80 | cleanup_event 81 | ) 82 | ) 83 | 84 | # Wait for ready event 85 | await asyncio.wait_for(ready_event.wait(), timeout=1.0) 86 | 87 | # Verify tools were created 88 | assert len(langchain_tools) == 1 89 | assert isinstance(langchain_tools[0], BaseTool) 90 | assert langchain_tools[0].name == "tool1" 91 | 92 | # Trigger cleanup 93 | cleanup_event.set() 94 | await task 95 | """ 96 | 97 | 98 | @pytest.mark.asyncio 99 | async def test_convert_mcp_to_langchain_tools_multiple_servers( 100 | mock_stdio_client, 101 | mock_client_session 102 | ): 103 | server_configs = { 104 | "server1": {"command": "cmd1", "args": []}, 105 | "server2": {"command": "cmd2", "args": []} 106 | } 107 | 108 | tools, cleanup = await convert_mcp_to_langchain_tools(server_configs) 109 | 110 | # Verify correct number of tools created 111 | assert len(tools) == 2 # One tool per server 112 | assert all(isinstance(tool, BaseTool) for tool in tools) 113 | 114 | # Test cleanup 115 | await cleanup() 116 | 117 | 118 | """ 119 | @pytest.mark.asyncio 120 | async def test_tool_execution(mock_stdio_client, mock_client_session): 121 | server_configs = { 122 | "test_server": {"command": "test", "args": []} 123 | } 124 | 125 | # Mock the tool execution response 126 | session = mock_client_session.return_value.__aenter__.return_value 127 | session.call_tool.return_value = MagicMock( 128 | isError=False, 129 | content={"result": "success"} 130 | ) 131 | 132 | tools, cleanup = await convert_mcp_to_langchain_tools(server_configs) 133 | 134 | # Test tool execution 135 | result = await tools[0]._arun(test_param="value") 136 | assert result == {"result": "success"} 137 | 138 | # Verify tool was called with correct parameters 139 | session.call_tool.assert_called_once_with("tool1", {"test_param": "value"}) 140 | 141 | await cleanup() 142 | """ 143 | 144 | 145 | @pytest.mark.asyncio 146 | async def test_tool_execution_error(mock_stdio_client, mock_client_session): 147 | server_configs = { 148 | "test_server": {"command": "test", "args": []} 149 | } 150 | 151 | # Mock error response 152 | session = mock_client_session.return_value.__aenter__.return_value 153 | session.call_tool.return_value = MagicMock( 154 | isError=True, 155 | content="Error message" 156 | ) 157 | 158 | tools, cleanup = await convert_mcp_to_langchain_tools(server_configs) 159 | 160 | # Test tool execution error 161 | with pytest.raises(Exception): 162 | await tools[0]._arun(test_param="value") 163 | 164 | await cleanup() 165 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](http://keepachangelog.com/) 6 | and this project adheres to [Semantic Versioning](http://semver.org/). 7 | 8 | 9 | ## [Unreleased] 10 | 11 | ### Changed 12 | - Minor updates to REAMDE and usage examples 13 | 14 | 15 | ## [0.2.13] - 2025-08-18 16 | 17 | ### Added 18 | - Usage examples of gpt-oss-120b/20b on Cerebras / Groq 19 | (confirmed to work with Cerebras and Groq) 20 | 21 | ### Changed 22 | - Update usage example to test newer OpenAI models 23 | 24 | 25 | ## [0.2.12] - 2025-08-06 26 | 27 | ### Fixed 28 | - Fix issues when showing README.md on PyPI 29 | 30 | 31 | ## [0.2.11] - 2025-08-06 32 | 33 | ### Changed 34 | - Improve the default logger 35 | - Update dependencies 36 | - Update README.md 37 | 38 | ### Added 39 | - Test usage with xAI Grok 40 | 41 | 42 | ## [0.2.10] - 2025-07-06 43 | 44 | ### Fixed 45 | - Properly export McpInitializationError 46 | 47 | 48 | ## [0.2.9] - 2025-07-05 49 | 50 | ### Changed 51 | - Update logger argument to take logging level (e.g. logging.DEBUG) as well 52 | - Separate out the tool conversion function into tool_adapter.py 53 | - Separate out the transport utility functions into ransport_utils.py 54 | - Update README.md 55 | - Update dependencies 56 | 57 | 58 | ## [0.2.8] - 2025-06-29 59 | 60 | ### Changed 61 | - Update documentation 62 | - Use leading underscores for the internal functions 63 | 64 | 65 | ## [0.2.7] - 2025-06-28 66 | 67 | ### Changed 68 | - Improve log messages for authentication pre-validation 69 | - Minor updates to README.md 70 | 71 | 72 | ## [0.2.6] - 2025-06-27 73 | 74 | ### Changed 75 | - Update dependencies 76 | 77 | 78 | ## [0.2.5] - 2025-06-27 79 | 80 | ### Added 81 | - Streamable HTTP Support with MCP 2025-03-26 backwards compatibility guidelines 82 | - Test servers and clients for Streamable HTTP 83 | - Support for Google GenAI LLMs 84 | - McpInitializationError for configuration and connection errors 85 | - TECHNOCAL.md that explains the implemenation details 86 | 87 | ### Changed 88 | - Update all documentation to include Streamable HTTP Support 89 | - Improve logging to show detailed transport selection reasoning 90 | - Rename testfiles/sse-auth-test-{client,server}.py to use "_" instead of "-" 91 | - Update dependencies 92 | 93 | 94 | ## [0.2.4] - 2025-04-24 95 | 96 | ### Changed 97 | - Make SingleMcpServerConfig public (it used to be McpServerConfig) 98 | - Improve documentation 99 | - Update README.md 100 | 101 | 102 | ## [0.2.3] - 2025-04-22 103 | 104 | ### Added 105 | - Add test files for SSE connection with authentication 106 | - Add Sphinx documentation with Google-style docstrings 107 | 108 | 109 | ## [0.2.2] - 2025-04-17 110 | 111 | ### Changed 112 | - Add new key "headers" to url based config for SSE server 113 | - Update README.md for the newly introduced headers key 114 | - Add README_DEV.md 115 | - Use double quotes instead of single quotes for string literals 116 | - Update dependencies 117 | 118 | 119 | ## [0.2.1] - 2025-04-11 120 | 121 | ### Changed 122 | - Update dependencies 123 | - Minor updates to the README.md 124 | 125 | 126 | ## [0.2.0] - 2025-04-04 127 | 128 | ### Changed 129 | - Add support for SSE and Websocket remote MCP servers 130 | - Introduced `McpServersConfig` type 131 | - Changed `stderr` of `McpServersConfig` to `errlog` to follow Python SDK more closely 132 | - Use double quotes instead of single quotes for string literals 133 | 134 | 135 | ## [0.1.11] - 2025-03-31 136 | 137 | ### Changed 138 | - Update the dependencies, esp. `Updated mcp v1.2.0 -> v1.6.0` (this fixes Issue #22) 139 | - Add `cwd` to `server_config` to specify the working directory for the MCP server to use 140 | - Add `stderr` to specify a filedescriptor to which MCP server's stderr is redirected 141 | - Rename `examples/example.py` to `testfiles/simple-usage.py` to avoid confusion 142 | 143 | 144 | ## [0.1.10] - 2025-03-25 145 | 146 | ### Changed 147 | - Make the logger fallback to a pre-configured logger if no root handlers exist 148 | - Remove unnecessarily added python-dotenv from the dependencies 149 | - Minor updates to README.me 150 | 151 | 152 | ## [0.1.9] - 2025-03-19 153 | 154 | ### Changed 155 | - Update LLM models used in example.py 156 | 157 | 158 | ## [0.1.8] - 2025-03-13 159 | 160 | ### Fixed 161 | - [PR #14](https://github.com/hideya/langchain-mcp-tools-py/pull/14): Fix: Handle JSON Schema type: ["string", "null"] for Notion MCP tools 162 | 163 | ### Changed 164 | - Minor updates to README.me and example.py 165 | 166 | 167 | ## [0.1.7] - 2025-02-21 168 | 169 | ### Fixed 170 | - [Issue #11](https://github.com/hideya/langchain-mcp-tools-py/issues/11): Move some dev dependencies which are mistakenly in dependencies to the right section 171 | 172 | 173 | ## [0.1.6] - 2025-02-20 174 | 175 | ### Added 176 | - `make test-publish` target to handle publication more carefully 177 | 178 | ### Changed 179 | - Estimate the size of returning text in a simpler way 180 | - Return a text with reasonable explanation when no text return found 181 | 182 | 183 | ## [0.1.5] - 2025-02-12 184 | 185 | ### Fixed 186 | - [Issue #8](https://github.com/hideya/langchain-mcp-tools-py/issues/8): Content field of tool result is wrong 187 | - Better checks when converting MCP's results into `str` 188 | 189 | ### Changed 190 | - Update example code in README.md to use `claude-3-5-sonnet-latest` 191 | instead of `haiku` which is sometimes less capable to handle results from MCP 192 | -------------------------------------------------------------------------------- /testfiles/sse_auth_test_client.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | SSE Authentication Test Client for MCP 4 | 5 | This client demonstrates how to connect to an authenticated MCP SSE server 6 | using the langchain-mcp-tools library. It tests the complete authentication 7 | flow and MCP transport auto-detection. 8 | 9 | Key Features: 10 | ============= 11 | 12 | 1. **JWT Authentication**: Generates JWT tokens compatible with the test server 13 | 2. **Transport Auto-Detection**: Tests the MCP specification's transport detection 14 | - First attempts Streamable HTTP (POST InitializeRequest) 15 | - Falls back to SSE on 4xx errors 16 | 3. **Tool Testing**: Demonstrates calling authenticated MCP tools 17 | 4. **Error Handling**: Shows proper cleanup and error management 18 | 19 | Usage: 20 | ====== 21 | 22 | 1. Start the SSE authentication test server: 23 | uv run testfiles/sse_auth_test_server.py 24 | 25 | 2. Run this client: 26 | uv run testfiles/sse_auth_test_client.py 27 | 28 | Expected Flow: 29 | ============== 30 | 31 | 1. Client generates JWT token matching server's secret 32 | 2. Client tests Streamable HTTP (receives 405 Method Not Allowed) 33 | 3. Client falls back to SSE transport 34 | 4. Client establishes authenticated SSE connection 35 | 5. Client lists and executes tools with authentication 36 | 6. Client cleans up connections 37 | 38 | Authentication: 39 | =============== 40 | 41 | The client uses the same JWT configuration as the server: 42 | - Secret: MCP_TEST_SECRET (hardcoded for testing) 43 | - Algorithm: HS512 44 | - Expiry: 60 minutes 45 | - Claims: sub=test-client, iat, exp 46 | 47 | For production use: 48 | - Use proper secret management 49 | - Implement token refresh 50 | - Add proper error handling for auth failures 51 | """ 52 | 53 | import asyncio 54 | import logging 55 | import os 56 | import sys 57 | from datetime import datetime, timedelta 58 | 59 | try: 60 | import jwt 61 | except ImportError as e: 62 | print(f"\nError: Required package not found: {e}") 63 | print("Please ensure all required packages are installed\n") 64 | sys.exit(1) 65 | 66 | # Import the langchain-mcp-tools library 67 | from langchain_mcp_tools import ( 68 | convert_mcp_to_langchain_tools, 69 | McpServersConfig, 70 | ) 71 | 72 | 73 | # Configuration and Setup 74 | # ======================= 75 | 76 | def init_logger() -> logging.Logger: 77 | """ 78 | Initialize a simple logger for the client. 79 | 80 | Configures logging to show INFO level messages with a clean format 81 | suitable for monitoring the MCP connection and tool execution. 82 | 83 | Returns: 84 | logging.Logger: Configured logger instance 85 | """ 86 | logging.basicConfig( 87 | level=logging.INFO, 88 | format="\x1b[90m%(levelname)s:\x1b[0m %(message)s" 89 | ) 90 | return logging.getLogger() 91 | 92 | 93 | # JWT Configuration - must match the server exactly 94 | JWT_SECRET = "MCP_TEST_SECRET" 95 | JWT_ALGORITHM = "HS512" 96 | 97 | 98 | def create_jwt_token(expiry_minutes=60) -> str: 99 | """ 100 | Create a JWT token for authenticating with the MCP server. 101 | 102 | This function generates a JWT token with the same configuration as the 103 | server expects. The token includes standard claims (sub, iat, exp) and 104 | is signed with the shared secret. 105 | 106 | Note: In production, use proper secret management and token refresh patterns. 107 | 108 | Args: 109 | expiry_minutes: Token expiry time in minutes (default: 60) 110 | 111 | Returns: 112 | str: JWT token string ready for Authorization header 113 | """ 114 | expiration = datetime.utcnow() + timedelta(minutes=expiry_minutes) 115 | payload = { 116 | "sub": "test-client", 117 | "exp": expiration, 118 | "iat": datetime.utcnow(), 119 | } 120 | token = jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM) 121 | return token 122 | 123 | 124 | async def run_client(server_port: int, logger: logging.Logger) -> None: 125 | """ 126 | Run the client that connects to the server with JWT authentication. 127 | 128 | Args: 129 | server_port: Port where the server is running 130 | logger: Logger instance 131 | """ 132 | # Generate JWT token for authentication 133 | bearer_token = create_jwt_token() 134 | print("Generated JWT token for authentication") 135 | 136 | # Configure MCP servers with authentication header 137 | mcp_servers: McpServersConfig = { 138 | "sse-auth-test-server": { 139 | "url": f"http://localhost:{server_port}/sse", 140 | "headers": {"Authorization": f"Bearer {bearer_token}"} 141 | }, 142 | } 143 | 144 | try: 145 | # Convert MCP tools to LangChain tools 146 | print("Connecting to server and converting tools...") 147 | tools, cleanup = await convert_mcp_to_langchain_tools( 148 | mcp_servers, 149 | logger 150 | ) 151 | 152 | print("Successfully connected!" 153 | f" Available tools: {[tool.name for tool in tools]}") 154 | 155 | # Test each tool directly 156 | for tool in tools: 157 | print(f"\nTesting tool: {tool.name}") 158 | if tool.name == "hello": 159 | result = await tool._arun(name="Client") 160 | print(f"Result: {result}") 161 | elif tool.name == "echo": 162 | result = await tool._arun( 163 | message="This is a test message with authentication" 164 | ) 165 | print(f"Result: {result}") 166 | 167 | print("\nAll tools tested successfully!") 168 | 169 | finally: 170 | # Clean up connections 171 | if 'cleanup' in locals(): 172 | print("Cleaning up connections...") 173 | await cleanup() 174 | 175 | 176 | # Entry Point 177 | # =========== 178 | 179 | if __name__ == "__main__": 180 | """ 181 | Main entry point for the test client. 182 | 183 | This script can be run directly to test the MCP SSE authentication flow. 184 | It expects the test server to be running on the specified port (default: 9000). 185 | 186 | Environment Variables: 187 | - PORT: Port where the test server is running (default: 9000) 188 | 189 | Expected Output: 190 | 1. JWT token generation message 191 | 2. Connection and transport detection logs 192 | 3. Tool discovery and testing results 193 | 4. Cleanup confirmation 194 | 195 | The script demonstrates a complete end-to-end test of: 196 | - MCP transport auto-detection 197 | - JWT authentication 198 | - Tool discovery and execution 199 | - Proper connection cleanup 200 | """ 201 | print("=== SSE Authentication Test Client ===") 202 | port = int(os.environ.get("PORT", 9000)) 203 | asyncio.run(run_client(port, init_logger())) 204 | print("===================================") 205 | -------------------------------------------------------------------------------- /src/langchain_mcp_tools/tool_adapter.py: -------------------------------------------------------------------------------- 1 | """MCP to LangChain tool adapter classes. 2 | 3 | This module contains the adapter class that converts MCP tools to LangChain 4 | BaseTool format, handling async execution, error handling, and result formatting. 5 | """ 6 | 7 | import logging 8 | from typing import Any, NoReturn 9 | 10 | try: 11 | from jsonschema_pydantic import jsonschema_to_pydantic # type: ignore 12 | from langchain_core.tools import BaseTool, ToolException 13 | from mcp import ClientSession 14 | import mcp.types as mcp_types 15 | from pydantic import BaseModel 16 | except ImportError as e: 17 | print(f"\nError: Required package not found: {e}") 18 | print("Please ensure all required packages are installed\n") 19 | import sys 20 | sys.exit(1) 21 | 22 | 23 | def _fix_schema(schema: dict) -> dict: 24 | """Converts JSON Schema "type": ["string", "null"] to "anyOf" format. 25 | 26 | Args: 27 | schema: A JSON schema dictionary 28 | 29 | Returns: 30 | Modified schema with converted type formats 31 | """ 32 | if isinstance(schema, dict): 33 | if "type" in schema and isinstance(schema["type"], list): 34 | schema["anyOf"] = [{"type": t} for t in schema["type"]] 35 | del schema["type"] # Remove "type" and standardize to "anyOf" 36 | for key, value in schema.items(): 37 | schema[key] = _fix_schema(value) # Apply recursively 38 | return schema 39 | 40 | 41 | def create_mcp_langchain_adapter( 42 | tool: mcp_types.Tool, 43 | session: ClientSession, 44 | server_name: str, 45 | logger: logging.Logger 46 | ) -> BaseTool: 47 | """Creates a LangChain tool adapter for an MCP tool. 48 | 49 | This function creates a LangChain-compatible tool that wraps an MCP tool, 50 | handling async execution, error handling, and result formatting. 51 | 52 | Args: 53 | tool: The MCP tool to wrap 54 | session: The MCP client session for tool execution 55 | server_name: Server name for logging context 56 | logger: Logger instance for debugging and monitoring 57 | 58 | Returns: 59 | A LangChain BaseTool instance that wraps the MCP tool 60 | """ 61 | 62 | # Create local variable to work around Python scoping: 63 | # class definitions can access enclosing function locals but not 64 | # parameters directly 65 | client_session = session 66 | 67 | class McpToLangChainAdapter(BaseTool): 68 | """Adapter class to convert MCP tool to LangChain format. 69 | 70 | This adapter handles the conversion between MCP's async tool interface 71 | and LangChain's tool format, including argument validation, execution, 72 | and result formatting. 73 | 74 | Features: 75 | - JSON Schema to Pydantic model conversion for argument validation 76 | - Async-only execution (raises NotImplementedError for sync calls) 77 | - Automatic result formatting from MCP TextContent to strings 78 | - Error handling with ToolException for MCP tool failures 79 | - Comprehensive logging of tool input/output and execution metrics 80 | """ 81 | name: str = tool.name or "NO NAME" 82 | description: str = tool.description or "" 83 | # Convert JSON schema to Pydantic model for argument validation 84 | args_schema: type[BaseModel] = jsonschema_to_pydantic( 85 | _fix_schema(tool.inputSchema) # Apply schema conversion 86 | ) 87 | session: ClientSession = client_session 88 | 89 | def _run(self, **kwargs: Any) -> NoReturn: 90 | """Synchronous execution is not supported for MCP tools. 91 | 92 | MCP tools are inherently async, so this method always raises 93 | NotImplementedError to direct users to use the async version. 94 | 95 | Raises: 96 | NotImplementedError: Always, as MCP tools only support async operations 97 | """ 98 | raise NotImplementedError( 99 | "MCP tools only support async operations" 100 | ) 101 | 102 | async def _arun(self, **kwargs: Any) -> Any: 103 | """Asynchronously executes the tool with given arguments. 104 | 105 | This method handles the actual execution of the MCP tool, including 106 | logging, error handling, and result formatting. It converts MCP's 107 | response format to LangChain's expected string format. 108 | 109 | Args: 110 | **kwargs: Arguments to be passed to the MCP tool 111 | 112 | Returns: 113 | Formatted response from the MCP tool as a string 114 | 115 | Raises: 116 | ToolException: If the tool execution fails 117 | """ 118 | logger.info(f'MCP tool "{server_name}"/"{self.name}" ' 119 | f"received input: {kwargs}") 120 | 121 | try: 122 | result = await self.session.call_tool(self.name, kwargs) 123 | 124 | # Check for MCP tool execution errors 125 | if hasattr(result, "isError") and result.isError: 126 | raise ToolException( 127 | f"Tool execution failed: {result.content}" 128 | ) 129 | 130 | if not hasattr(result, "content"): 131 | return str(result) 132 | 133 | # Convert MCP TextContent items to string format 134 | # The library uses LangChain's `response_format: 'content'` (the default), 135 | # which only supports text strings and BaseTool._arun() expects string return type 136 | try: 137 | result_content_text = "\n\n".join( 138 | item.text 139 | for item in result.content 140 | if isinstance(item, mcp_types.TextContent) 141 | ) 142 | # Alternative approach using JSON serialization (preserved for reference): 143 | # text_items = [ 144 | # item 145 | # for item in result.content 146 | # if isinstance(item, mcp_types.TextContent) 147 | # ] 148 | # result_content_text = to_json(text_items).decode() 149 | 150 | except KeyError as e: 151 | result_content_text = ( 152 | f"Error in parsing result.content: {str(e)}; " 153 | f"contents: {repr(result.content)}" 154 | ) 155 | 156 | # Log rough result size for monitoring 157 | size = len(result_content_text.encode()) 158 | logger.info(f'MCP tool "{server_name}"/"{self.name}" ' 159 | f"received result (size: {size})") 160 | 161 | # If no text content, return a clear message 162 | # describing the situation. 163 | result_content_text = ( 164 | result_content_text or 165 | "No text content available in response" 166 | ) 167 | 168 | return result_content_text 169 | 170 | except Exception as e: 171 | logger.warn( 172 | f'MCP tool "{server_name}"/"{self.name}" ' 173 | f"caused error: {str(e)}" 174 | ) 175 | if self.handle_tool_error: 176 | return f"Error executing MCP tool: {str(e)}" 177 | raise 178 | 179 | return McpToLangChainAdapter() 180 | -------------------------------------------------------------------------------- /TECHNICAL.md: -------------------------------------------------------------------------------- 1 | # Technical Details 2 | 3 | This document contains important implementation details, design decisions, and lessons learned during the development of `langchain-mcp-tools`. 4 | 5 | ## Table of Contents 6 | 7 | - [Authentication Pre-validation](#authentication-pre-validation) 8 | - [Error Handling Strategy](#error-handling-strategy) 9 | - [MCP Client Library Issues](#mcp-client-library-issues) 10 | - [Architecture Decisions](#architecture-decisions) 11 | - [Development Guidelines](#development-guidelines) 12 | 13 | ## Authentication Pre-validation 14 | 15 | ### The Problem We Discovered 16 | 17 | During development, we encountered an issue where authentication failures (401 Unauthorized) were causing **async generator cleanup errors** in the underlying MCP Python client library, which retsults in massive logs that make it difficult to identify that authentication is the root cause. 18 | 19 | #### Symptoms 20 | ``` 21 | [ERROR] an error occurred during closing of asynchronous generator 22 | asyncgen: 23 | + Exception Group Traceback (most recent call last): 24 | | File "/.../anyio/_backends/_asyncio.py", line 772, in __aexit__ 25 | | raise BaseExceptionGroup( 26 | | BaseExceptionGroup: unhandled errors in a TaskGroup (2 sub-exceptions) 27 | +-+---------------- 1 ---------------- 28 | | Traceback (most recent call last): 29 | | File "/.../mcp/client/streamable_http.py", line 368, in handle_request_async 30 | | await self._handle_post_request(ctx) 31 | | File "/.../mcp/client/streamable_http.py", line 252, in _handle_post_request 32 | | response.raise_for_status() 33 | | File "/.../httpx/_models.py", line 829, in raise_for_status 34 | | raise HTTPStatusError(message, request=request, response=self) 35 | | httpx.HTTPStatusError: Client error '401 Unauthorized' for url '...' 36 | ``` 37 | 38 | #### Root Cause Analysis 39 | 40 | 1. **MCP Client Library Issue?**: The `streamablehttp_client` async generator may have improper cleanup handling when authentication fails during the connection setup phase. 41 | 42 | 2. **Exception Propagation Failure**: Instead of properly propagating the `HTTPStatusError`, the async generator cleanup failure masks the real authentication error. 43 | 44 | 3. **User Experience Impact**: Users see cryptic async generator errors instead of clear "authentication failed" messages. 45 | 46 | ### Our Solution: Authentication Pre-validation 47 | 48 | We implemented `validate_auth_before_connection()` to detect authentication issues **before** attempting the actual MCP connection. 49 | This ensures that clear error messages like `Authentication failed (401 Unauthorized)` or `Authentication failed (403 Forbidden)` appear at the end of the logs, rather than being buried in the middle of extensive error output. 50 | 51 | #### How It Works 52 | 53 | ```python 54 | async def validate_auth_before_connection( 55 | url_str: str, 56 | headers: dict[str, str] | None = None, 57 | timeout: float = 30.0, 58 | auth: httpx.Auth | None = None, 59 | logger: logging.Logger = logging.getLogger(__name__) 60 | ) -> tuple[bool, str]: 61 | """Pre-validate authentication with a simple HTTP request.""" 62 | ``` 63 | 64 | **Key Implementation Details:** 65 | 66 | 1. **Uses Proper MCP Protocol**: Sends a valid MCP `InitializeRequest` to test authentication 67 | 2. **Detects Auth Failures Early**: Catches 401, 402, 403 errors before MCP connection 68 | 3. **Provides Clear Errors**: Returns descriptive error messages for users 69 | 70 | #### Integration Pattern 71 | 72 | ```python 73 | # In connect_to_mcp_server() 74 | if url_scheme in ["http", "https"]: 75 | # Pre-validate authentication to avoid MCP async generator cleanup bugs 76 | auth_valid, auth_message = await validate_auth_before_connection( 77 | url_str, headers=headers, timeout=timeout or 30.0, auth=auth, logger=logger 78 | ) 79 | 80 | if not auth_valid: 81 | raise McpInitializationError(auth_message, server_name=server_name) 82 | 83 | # Only proceed with MCP connection if auth is valid 84 | transport = await exit_stack.enter_async_context( 85 | streamablehttp_client(url_str, **kwargs) 86 | ) 87 | ``` 88 | 89 | ## Error Handling Strategy 90 | 91 | ### Custom Exception Hierarchy 92 | 93 | We moved away from generic `ValueError` to a purpose-built exception system: 94 | 95 | ```python 96 | class McpInitializationError(Exception): 97 | """Raised when MCP server initialization fails.""" 98 | 99 | def __init__(self, message: str, server_name: str | None = None): 100 | self.server_name = server_name 101 | super().__init__(message) 102 | 103 | def __str__(self) -> str: 104 | if self.server_name: 105 | return f'MCP server "{self.server_name}": {super().__str__()}' 106 | return super().__str__() 107 | ``` 108 | 109 | **Benefits:** 110 | - **Better semantics**: `McpInitializationError` vs generic `ValueError` 111 | - **Server context**: `server_name` attribute for debugging 112 | - **Consistent formatting**: Automatic server name inclusion in error messages 113 | - **Extensibility**: Easy to add more specific exception types later 114 | 115 | ### Error Context Propagation 116 | 117 | All errors now include server context: 118 | 119 | ```python 120 | try: 121 | tools, cleanup = await convert_mcp_to_langchain_tools(server_configs) 122 | except McpInitializationError as e: 123 | print(f"Failed to initialize server '{e.server_name}': {e}") 124 | # Server name is available for debugging 125 | ``` 126 | 127 | ## MCP Client Library Issues 128 | 129 | ### Known Issues We've Encountered 130 | 131 | 1. **Async Generator Cleanup Bug** (Primary Issue) 132 | - **Affects**: `streamablehttp_client` when authentication fails 133 | - **Workaround**: Pre-validate authentication 134 | - **Status**: Should be reported to MCP Python SDK team 135 | 136 | 2. **Transport Tuple Variations** 137 | - **Issue**: Different transports return different tuple formats 138 | - **SSE/Stdio**: 2-tuple `(read, write)` 139 | - **Streamable HTTP**: 3-tuple `(read, write, session_info)` 140 | - **Solution**: Handle both formats gracefully 141 | 142 | ### Best Practices for MCP Client Usage 143 | 144 | 1. **Always Use AsyncExitStack**: Proper resource management is critical 145 | 2. **Pre-validate Authentication**: Especially for HTTP transports 146 | 3. **Handle Both Transport Formats**: Check tuple length before unpacking 147 | 4. **Comprehensive Error Handling**: Wrap MCP operations in try-catch blocks 148 | 5. **Proper Cleanup**: Always call cleanup functions 149 | 150 | ## Architecture Decisions 151 | 152 | ### User-Controlled Cleanup 153 | 154 | Instead of automatic cleanup, we provide user-controlled cleanup via `AsyncExitStack`: 155 | 156 | ```python 157 | async def convert_mcp_to_langchain_tools( 158 | server_configs: McpServersConfig, 159 | logger: logging.Logger | None = None 160 | ) -> tuple[list[BaseTool], McpServerCleanupFn]: 161 | 162 | async_exit_stack = AsyncExitStack() 163 | 164 | # ... initialize servers ... 165 | 166 | async def mcp_cleanup() -> None: 167 | """User calls this when ready to cleanup.""" 168 | await async_exit_stack.aclose() 169 | 170 | return langchain_tools, mcp_cleanup 171 | ``` 172 | 173 | **Benefits:** 174 | - **User Control**: Users decide when to cleanup resources 175 | - **Batch Operations**: All servers cleaned up together 176 | - **Exception Safety**: Resources tracked even if individual connections fail 177 | - **Flexibility**: Works with different application lifecycles 178 | 179 | ### Parallel Server Initialization 180 | 181 | We initialize multiple servers concurrently for efficiency: 182 | 183 | ```python 184 | # Initialize all servers in parallel 185 | for server_name, server_config in server_configs.items(): 186 | transport = await connect_to_mcp_server( 187 | server_name, server_config, async_exit_stack, logger 188 | ) 189 | transports.append(transport) 190 | ``` 191 | 192 | **Note**: For stdio servers, the `await` only blocks until subprocess spawn, then servers initialize in parallel. 193 | 194 | ### Transport Abstraction 195 | 196 | We maintain transport-agnostic interfaces while handling transport-specific details internally: 197 | 198 | - **Unified Configuration**: Same interface for all transport types 199 | - **Automatic Detection**: Smart transport selection based on URL/command 200 | - **Fallback Logic**: Streamable HTTP → SSE fallback per MCP spec 201 | 202 | ## Development Guidelines 203 | 204 | ### Adding New Features 205 | 206 | 1. **Maintain Backward Compatibility**: Existing user code should continue working 207 | 2. **Add Tests**: Cover both success and failure scenarios 208 | 3. **Update Documentation**: Both code docstrings and this README 209 | 4. **Consider Error Handling**: How do errors propagate to users? 210 | 211 | ### Testing Authentication Features 212 | 213 | When testing authentication features: 214 | 215 | 1. **Test Valid Auth**: Ensure successful connections work 216 | 2. **Test Invalid Auth**: Verify clear error messages for 401/403 217 | 3. **Test Network Issues**: Handle connection failures gracefully 218 | 4. **Test Edge Cases**: Empty headers, malformed tokens, etc. 219 | 220 | ### MCP Library Compatibility 221 | 222 | When updating MCP library dependencies: 223 | 224 | 1. **Test Authentication Flows**: Verify our workarounds still work 225 | 2. **Check Transport Changes**: New -------------------------------------------------------------------------------- /testfiles/simple_usage.py: -------------------------------------------------------------------------------- 1 | # Standard library imports 2 | import asyncio 3 | import logging 4 | import os 5 | import sys 6 | from contextlib import ExitStack 7 | 8 | # Third-party imports 9 | try: 10 | from dotenv import load_dotenv 11 | from langchain.chat_models import init_chat_model 12 | from langchain.schema import HumanMessage 13 | from langgraph.prebuilt import create_react_agent 14 | except ImportError as e: 15 | print(f"\nError: Required package not found: {e}") 16 | print("Please ensure all required packages are installed\n") 17 | sys.exit(1) 18 | 19 | # Local application imports 20 | from langchain_mcp_tools import ( 21 | convert_mcp_to_langchain_tools, 22 | McpServersConfig, 23 | ) 24 | from remote_server_utils import start_remote_mcp_server_locally 25 | 26 | 27 | # A very simple logger 28 | def init_logger() -> logging.Logger: 29 | logging.basicConfig( 30 | # level=logging.DEBUG, 31 | level=logging.INFO, 32 | format="\x1b[90m%(levelname)s:\x1b[0m %(message)s" 33 | ) 34 | return logging.getLogger() 35 | 36 | 37 | async def run() -> None: 38 | load_dotenv() 39 | 40 | # If you are interested in testing the SSE/WS server connection, uncomment 41 | # one of the following code snippets and one of the appropriate "weather" 42 | # server configurations, while commenting out the others. 43 | 44 | # sse_server_process, sse_server_port = start_remote_mcp_server_locally( 45 | # "SSE", "npx -y @h1deya/mcp-server-weather") 46 | 47 | # ws_server_process, ws_server_port = start_remote_mcp_server_locally( 48 | # "WS", "npx -y @h1deya/mcp-server-weather") 49 | 50 | try: 51 | mcp_servers: McpServersConfig = { 52 | "filesystem": { 53 | # "transport": "stdio", // optional 54 | # "type": "stdio", // optional: VSCode-style config works too 55 | "command": "npx", 56 | "args": [ 57 | "-y", 58 | "@modelcontextprotocol/server-filesystem", 59 | "." # path to a directory to allow access to 60 | ], 61 | # "cwd": "/tmp" # the working dir to be use by the server 62 | }, 63 | 64 | "fetch": { 65 | "command": "uvx", 66 | "args": [ 67 | "mcp-server-fetch" 68 | ] 69 | }, 70 | 71 | "us-weather": { # US weather only 72 | "command": "npx", 73 | "args": [ 74 | "-y", 75 | "@h1deya/mcp-server-weather" 76 | ] 77 | }, 78 | 79 | # # Auto-detection example: This will try Streamable HTTP first, then fallback to SSE 80 | # "us-weather": { 81 | # "url": f"http://localhost:{sse_server_port}/sse" 82 | # }, 83 | 84 | # # THIS DOESN'T WORK: Example of explicit transport selection: 85 | # "us-weather": { 86 | # "url": f"http://localhost:{streamable_http_server_port}/mcp", 87 | # "transport": "streamable_http" # Force Streamable HTTP 88 | # # "type": "http" # VSCode-style config also works instead of the above 89 | # }, 90 | 91 | # "us-weather": { 92 | # "url": f"http://localhost:{sse_server_port}/sse", 93 | # "transport": "sse" # Force SSE 94 | # # "type": "sse" # This also works instead of the above 95 | # }, 96 | 97 | # "us-weather": { 98 | # "url": f"ws://localhost:{ws_server_port}/message", 99 | # # optionally `"transport": "ws"` or `"type": "ws"` 100 | # }, 101 | 102 | # # https://github.com/modelcontextprotocol/servers/tree/main/src/brave-search 103 | # "brave-search": { 104 | # "command": "npx", 105 | # "args": [ "-y", "@modelcontextprotocol/server-brave-search"], 106 | # "env": { "BRAVE_API_KEY": os.environ.get('BRAVE_API_KEY') } 107 | # }, 108 | 109 | # # Example of authentication via Authorization header 110 | # # https://github.com/github/github-mcp-server?tab=readme-ov-file#remote-github-mcp-server 111 | # "github": { 112 | # # To avoid auto protocol fallback, specify the protocol explicitly when using authentication 113 | # "type": "http", 114 | # # "__pre_validate_authentication": False, 115 | # "url": "https://api.githubcopilot.com/mcp/", 116 | # "headers": { 117 | # "Authorization": f"Bearer {os.environ.get('GITHUB_PERSONAL_ACCESS_TOKEN')}" 118 | # } 119 | # }, 120 | 121 | # # NOTE: comment out "fetch" when you use "notion". 122 | # # They both have a tool named "fetch," which causes a conflict. 123 | # # 124 | # # Run Notion remote MCP server via mcp-remote 125 | # "notion": { 126 | # "command": "npx", # OAuth via "mcp-remote" 127 | # "args": ["-y", "mcp-remote", "https://mcp.notion.com/mcp"], 128 | # }, 129 | 130 | # # The following Notion local MCP server is not recommended anymore? 131 | # # Refs: 132 | # # - https://developers.notion.com/docs/get-started-with-mcp 133 | # # - https://www.npmjs.com/package/@notionhq/notion-mcp-server 134 | # "notion": { 135 | # "command": "npx", 136 | # "args": ["-y", "@notionhq/notion-mcp-server"], 137 | # "env": { 138 | # "NOTION_TOKEN": os.environ.get("NOTION_INTEGRATION_SECRET", "") 139 | # } 140 | # }, 141 | 142 | # "airtable": { 143 | # "transport": "stdio", 144 | # "command": "npx", 145 | # "args": ["-y", "airtable-mcp-server"], 146 | # "env": { 147 | # "AIRTABLE_API_KEY": f"{os.environ.get('AIRTABLE_API_KEY')}" 148 | # } 149 | # }, 150 | 151 | # "sqlite": { 152 | # "command": "uvx", 153 | # "args": [ 154 | # "mcp-server-sqlite", 155 | # "--db-path", 156 | # "mcp-server-sqlite-test.sqlite3" 157 | # ], 158 | # "cwd": "/tmp" # the working directory to be use by the server 159 | # }, 160 | 161 | # "sequential-thinking": { 162 | # "command": "npx", 163 | # "args": [ 164 | # "-y", 165 | # "@modelcontextprotocol/server-sequential-thinking" 166 | # ] 167 | # }, 168 | 169 | # "playwright": { 170 | # "command": "npx", 171 | # "args": [ 172 | # "@playwright/mcp@latest" 173 | # ] 174 | # }, 175 | } 176 | 177 | # If you are interested in MCP server's stderr redirection, 178 | # uncomment the following code snippets. 179 | # 180 | # Set a file-like object to which MCP server's stderr is redirected 181 | log_file_exit_stack = ExitStack() 182 | for server_name in mcp_servers: 183 | server_config = mcp_servers[server_name] 184 | # Skip URL-based servers (no command) 185 | if "command" not in server_config: 186 | continue 187 | log_path = f"mcp-server-{server_name}.log" 188 | log_file = open(log_path, "w") 189 | server_config["errlog"] = log_file 190 | log_file_exit_stack.callback(log_file.close) 191 | 192 | tools, cleanup = await convert_mcp_to_langchain_tools( 193 | mcp_servers, 194 | # logging.DEBUG 195 | # init_logger() 196 | ) 197 | 198 | ### https://docs.anthropic.com/en/docs/about-claude/pricing 199 | ### https://console.anthropic.com/settings/billing 200 | # llm = init_chat_model("anthropic:claude-3-5-haiku-latest") 201 | # llm = init_chat_model("anthropic:claude-sonnet-4-0") 202 | 203 | ### https://platform.openai.com/docs/pricing 204 | ### https://platform.openai.com/settings/organization/billing/overview 205 | # llm = init_chat_model("openai:gpt-4.1-nano") 206 | # llm = init_chat_model("openai:gpt-5-mini") 207 | 208 | ### https://ai.google.dev/gemini-api/docs/pricing 209 | ### https://console.cloud.google.com/billing 210 | llm = init_chat_model("google_genai:gemini-2.5-flash") 211 | # llm = init_chat_model("google_genai:gemini-2.5-pro") 212 | 213 | ### https://console.x.ai 214 | # llm = init_chat_model("xai:grok-3-mini") 215 | # llm = init_chat_model("xai:grok-4") 216 | 217 | ### https://console.groq.com/docs/rate-limits 218 | ### https://console.groq.com/dashboard/usage 219 | # llm = init_chat_model("groq:openai/gpt-oss-20b") 220 | # llm = init_chat_model("groq:openai/gpt-oss-120b") 221 | 222 | ### https://cloud.cerebras.ai 223 | ### https://inference-docs.cerebras.ai/models/openai-oss 224 | ### No init_chat_model() support for "cerebras" yet 225 | # from langchain_cerebras import ChatCerebras 226 | # llm = ChatCerebras(model="gpt-oss-120b") 227 | 228 | agent = create_react_agent( 229 | llm, 230 | tools 231 | ) 232 | 233 | print("\x1b[32m"); # color to green 234 | print("\nLLM model:", getattr(llm, 'model', getattr(llm, 'model_name', 'unknown'))) 235 | print("\x1b[0m"); # reset the color 236 | 237 | query = "Are there any weather alerts in California?" 238 | # query = "Read the news headlines on bbc.com" 239 | # query = "Read and briefly summarize the LICENSE file" 240 | # query = "Tell me how many directories there are in `.`" 241 | # query = "Tell me about my GitHub profile" 242 | # query = ("Make a new table in DB and put items apple and orange with counts 123 and 345 respectively, " 243 | # "then increment the coutns by 1, and show all the items in the table.") 244 | # query = "Use sequential-thinking and plan a trip from Tokyo to San Francisco" 245 | # query = "Open the BBC.com page, then close it" 246 | # query = "Tell me about my Notion account" 247 | # query = "What's the news from Tokyo today?" 248 | 249 | print("\x1b[33m") # color to yellow 250 | print(query) 251 | print("\x1b[0m") # reset the color 252 | 253 | messages = [HumanMessage(content=query)] 254 | 255 | result = await agent.ainvoke({"messages": messages}) 256 | 257 | result_messages = result["messages"] 258 | # the last message should be an AIMessage 259 | response = result_messages[-1].content 260 | 261 | print("\x1b[36m") # color to cyan 262 | print(response) 263 | print("\x1b[0m") # reset the color 264 | 265 | finally: 266 | # cleanup can be undefined when an exeption occurs during initialization 267 | if "cleanup" in locals(): 268 | await cleanup() 269 | 270 | # the following only needed when testing the `errlog` key 271 | if "log_file_exit_stack" in locals(): 272 | log_file_exit_stack.close() 273 | 274 | # the followings only needed when testing the `url` key 275 | if "sse_server_process" in locals(): 276 | sse_server_process.terminate() 277 | if "ws_server_process" in locals(): 278 | ws_server_process.terminate() 279 | 280 | 281 | def main() -> None: 282 | asyncio.run(run()) 283 | 284 | 285 | if __name__ == "__main__": 286 | main() 287 | -------------------------------------------------------------------------------- /docs/langchain_mcp_tools_docs.py: -------------------------------------------------------------------------------- 1 | # Documentation-only version with simplified code 2 | import logging 3 | from typing import Any, Awaitable, Callable, Dict, List, Optional, TextIO, Tuple, TypedDict, Union, NotRequired 4 | 5 | # Import for forward reference 6 | try: 7 | from langchain_core.tools import BaseTool 8 | except ImportError: 9 | # For documentation only - create a placeholder if import fails 10 | class BaseTool: 11 | """Placeholder for LangChain BaseTool.""" 12 | pass 13 | 14 | 15 | class McpInitializationError(Exception): 16 | """Raised when MCP server initialization fails. 17 | 18 | This exception is raised when there are issues during MCP server setup, 19 | connection, or configuration validation. It includes the server name 20 | for better error context and debugging. 21 | 22 | Args: 23 | message: Description of the initialization error 24 | server_name: Optional name of the MCP server that failed 25 | """ 26 | 27 | def __init__(self, message: str, server_name: Optional[str] = None): 28 | self.server_name = server_name 29 | super().__init__(message) 30 | 31 | def __str__(self) -> str: 32 | if self.server_name: 33 | return f'MCP server "{self.server_name}": {super().__str__()}' 34 | return super().__str__() 35 | 36 | 37 | # Type definitions for public API 38 | class McpServerCommandBasedConfig(TypedDict): 39 | """Configuration for an MCP server launched via command line. 40 | 41 | This configuration is used for local MCP servers that are started as child 42 | processes using the stdio client. It defines the command to run, optional 43 | arguments, environment variables, working directory, and error logging 44 | options. 45 | 46 | Attributes: 47 | command: The executable command to run (e.g., "npx", "uvx", "python"). 48 | args: Optional list of command-line arguments to pass to the command. 49 | env: Optional dictionary of environment variables to set for the 50 | process. 51 | cwd: Optional working directory where the command will be executed. 52 | errlog: Optional file-like object for redirecting the server's stderr 53 | output. 54 | 55 | Example:: 56 | 57 | { 58 | "command": "npx", 59 | "args": ["-y", "@modelcontextprotocol/server-filesystem", "."], 60 | "env": {"NODE_ENV": "production"}, 61 | "cwd": "/path/to/working/directory", 62 | "errlog": open("server.log", "w") 63 | } 64 | """ 65 | command: str 66 | args: NotRequired[List[str] | None] 67 | env: NotRequired[Dict[str, str] | None] 68 | cwd: NotRequired[str | None] 69 | errlog: NotRequired[TextIO | None] 70 | 71 | 72 | class McpServerUrlBasedConfig(TypedDict): 73 | """Configuration for a remote MCP server accessed via URL. 74 | 75 | This configuration is used for remote MCP servers that are accessed via 76 | HTTP/HTTPS (Streamable HTTP, Server-Sent Events) or WebSocket connections. 77 | It defines the URL to connect to and optional HTTP headers for authentication. 78 | 79 | Note: Per MCP spec, clients should try Streamable HTTP first, then fallback 80 | to SSE on 4xx errors for maximum compatibility. 81 | 82 | Attributes: 83 | url: The URL of the remote MCP server. For HTTP/HTTPS servers, 84 | use http:// or https:// prefix. For WebSocket servers, 85 | use ws:// or wss:// prefix. 86 | transport: Optional transport type. Supported values: 87 | "streamable_http" or "http" (recommended, attempted first), 88 | "sse" (deprecated, fallback), "websocket" 89 | type: Optional alternative field name for transport (for compatibility) 90 | headers: Optional dictionary of HTTP headers to include in the request, 91 | typically used for authentication (e.g., bearer tokens). 92 | timeout: Optional timeout for HTTP requests (default: 30.0 seconds). 93 | sse_read_timeout: Optional timeout for SSE connections (SSE only). 94 | terminate_on_close: Optional flag to terminate on connection close. 95 | httpx_client_factory: Optional factory for creating HTTP clients. 96 | auth: Optional httpx authentication for requests. 97 | __pre_validate_authentication: Optional flag to skip auth validation 98 | (default: True). Set to False for OAuth flows that require 99 | complex authentication flows. 100 | 101 | Example for auto-detection (recommended):: 102 | 103 | { 104 | "url": "https://api.example.com/mcp", 105 | # Auto-tries Streamable HTTP first, falls back to SSE on 4xx 106 | "headers": {"Authorization": "Bearer token123"}, 107 | "timeout": 60.0 108 | } 109 | 110 | Example for explicit Streamable HTTP:: 111 | 112 | { 113 | "url": "https://api.example.com/mcp", 114 | "transport": "streamable_http", 115 | "headers": {"Authorization": "Bearer token123"}, 116 | "timeout": 60.0 117 | } 118 | 119 | Example for explicit SSE (legacy):: 120 | 121 | { 122 | "url": "https://example.com/mcp/sse", 123 | "transport": "sse", 124 | "headers": {"Authorization": "Bearer token123"} 125 | } 126 | 127 | Example for WebSocket:: 128 | 129 | { 130 | "url": "wss://example.com/mcp/ws", 131 | "transport": "websocket" 132 | } 133 | """ 134 | url: str 135 | transport: NotRequired[str] # Preferred field name 136 | type: NotRequired[str] # Alternative field name for compatibility 137 | headers: NotRequired[Dict[str, str] | None] 138 | timeout: NotRequired[float] 139 | sse_read_timeout: NotRequired[float] 140 | terminate_on_close: NotRequired[bool] 141 | httpx_client_factory: NotRequired[Any] # McpHttpClientFactory 142 | auth: NotRequired[Any] # httpx.Auth 143 | __prevalidate_authentication: NotRequired[bool] 144 | 145 | 146 | # Type for a single MCP server configuration, which can be either command-based or URL-based. 147 | SingleMcpServerConfig = Union[McpServerCommandBasedConfig, McpServerUrlBasedConfig] 148 | """Configuration for a single MCP server, either command-based or URL-based. 149 | 150 | This type represents the configuration for a single MCP server, which can be either: 151 | 152 | 1. A local server launched via command line (McpServerCommandBasedConfig) 153 | 2. A remote server accessed via URL (McpServerUrlBasedConfig) 154 | 155 | The type is determined by the presence of either the "command" key (for command-based) 156 | or the "url" key (for URL-based). 157 | """ 158 | 159 | # Configuration dictionary for multiple MCP servers 160 | McpServersConfig = Dict[str, SingleMcpServerConfig] 161 | """Configuration dictionary for multiple MCP servers. 162 | 163 | A dictionary mapping server names (as strings) to their respective configurations. 164 | Each server name acts as a logical identifier used for logging and debugging. 165 | The configuration for each server can be either command-based or URL-based. 166 | 167 | Example:: 168 | 169 | { 170 | "filesystem": { 171 | "command": "npx", 172 | "args": ["-y", "@modelcontextprotocol/server-filesystem", "."] 173 | }, 174 | "fetch": { 175 | "command": "uvx", 176 | "args": ["mcp-server-fetch"] 177 | }, 178 | "auto-detection-server": { 179 | "url": "https://api.example.com/mcp", 180 | # Will try Streamable HTTP first, fallback to SSE on 4xx 181 | "headers": {"Authorization": "Bearer token123"}, 182 | "timeout": 60.0 183 | }, 184 | "explicit-sse-server": { 185 | "url": "https://legacy.example.com/mcp/sse", 186 | "transport": "sse", 187 | "headers": {"Authorization": "Bearer token123"} 188 | } 189 | } 190 | """ 191 | 192 | 193 | # Type hint for cleanup function 194 | McpServerCleanupFn = Callable[[], Awaitable[None]] 195 | """Type for the async cleanup function returned by convert_mcp_to_langchain_tools. 196 | 197 | This function encapsulates the cleanup of all MCP server connections managed by 198 | the AsyncExitStack. When called, it properly closes all transport connections, 199 | sessions, and resources in the correct order. 200 | 201 | Important: Always call this function when you're done using the tools to prevent 202 | resource leaks and ensure graceful shutdown of MCP server connections. 203 | 204 | Example usage:: 205 | 206 | tools, cleanup = await convert_mcp_to_langchain_tools(server_configs) 207 | try: 208 | # Use tools with your LangChain application... 209 | result = await tools[0].arun(param="value") 210 | finally: 211 | # Always cleanup, even if exceptions occur 212 | await cleanup() 213 | """ 214 | 215 | 216 | async def convert_mcp_to_langchain_tools( 217 | server_configs: McpServersConfig, 218 | logger: Optional[logging.Logger] = None 219 | ) -> Tuple[List[BaseTool], McpServerCleanupFn]: 220 | """Initialize multiple MCP servers and convert their tools to LangChain format. 221 | 222 | This is the main entry point for the library. It orchestrates the complete 223 | lifecycle of multiple MCP server connections, from initialization through 224 | tool conversion to cleanup. Provides robust error handling and authentication 225 | pre-validation to prevent common MCP client library issues. 226 | 227 | Key Features: 228 | - Parallel initialization of multiple servers for efficiency 229 | - Authentication pre-validation for HTTP servers to prevent async generator bugs 230 | - Automatic transport selection and fallback per MCP specification 231 | - Comprehensive error handling with McpInitializationError 232 | - User-controlled cleanup via returned async function 233 | - Support for both local (stdio) and remote (HTTP/WebSocket) servers 234 | 235 | Transport Support: 236 | - stdio: Local command-based servers (npx, uvx, python, etc.) 237 | - streamable_http: Modern HTTP servers (recommended, tried first) 238 | - sse: Legacy Server-Sent Events HTTP servers (fallback) 239 | - websocket: WebSocket servers for real-time communication 240 | 241 | Error Handling: 242 | All configuration and connection errors are wrapped in McpInitializationError 243 | with server context for easy debugging. Authentication failures are detected 244 | early to prevent async generator cleanup issues in the MCP client library. 245 | 246 | Args: 247 | server_configs: Dictionary mapping server names to configurations. 248 | Each config can be either McpServerCommandBasedConfig for local 249 | servers or McpServerUrlBasedConfig for remote servers. 250 | logger: Optional logger instance. If None, creates a pre-configured 251 | logger with appropriate levels for MCP debugging. 252 | If a logging level (e.g., `logging.DEBUG`), the pre-configured 253 | logger will be initialized with that level. 254 | 255 | Returns: 256 | A tuple containing: 257 | 258 | - List[BaseTool]: All tools from all servers, ready for LangChain use 259 | - McpServerCleanupFn: Async function to properly shutdown all connections 260 | 261 | Raises: 262 | McpInitializationError: If any server fails to initialize with detailed context 263 | 264 | Example:: 265 | 266 | server_configs = { 267 | "local-filesystem": { 268 | "command": "npx", 269 | "args": ["-y", "@modelcontextprotocol/server-filesystem", "."] 270 | }, 271 | "remote-api": { 272 | "url": "https://api.example.com/mcp", 273 | "headers": {"Authorization": "Bearer your-token"}, 274 | "timeout": 30.0 275 | } 276 | } 277 | 278 | try: 279 | tools, cleanup = await convert_mcp_to_langchain_tools(server_configs) 280 | 281 | # Use tools with your LangChain application 282 | for tool in tools: 283 | result = await tool.arun(**tool_args) 284 | 285 | except McpInitializationError as e: 286 | print(f"Failed to initialize MCP server '{e.server_name}': {e}") 287 | 288 | finally: 289 | # Always cleanup when done 290 | await cleanup() 291 | """ 292 | # This is just a documentation stub 293 | pass 294 | -------------------------------------------------------------------------------- /testfiles/sse_auth_test_server.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | SSE Authentication Test Server for MCP 4 | 5 | This server demonstrates the correct pattern for implementing JWT authentication 6 | with FastMCP SSE servers while maintaining compatibility with MCP transport 7 | auto-detection. 8 | 9 | Key Architectural Decisions: 10 | ========================== 11 | 12 | 1. **Starlette vs FastAPI**: Uses pure Starlette instead of FastAPI to avoid 13 | middleware conflicts with FastMCP's SSE streaming implementation. FastAPI's 14 | middleware stack can interfere with ASGI message types during SSE cleanup. 15 | 16 | 2. **Transport Detection**: Implements MCP specification requirement that SSE 17 | servers return 405 Method Not Allowed for POST requests to SSE endpoints 18 | without session_id to indicate SSE-only transport support. 19 | 20 | 3. **Authentication Pattern**: Uses global token storage pattern (common in 21 | community examples) where middleware extracts and stores JWT tokens for 22 | FastMCP tools to access via require_auth() function. 23 | 24 | 4. **ASGI Wrapper**: Custom AuthSSEApp class wraps FastMCP's SSE app to handle 25 | authentication at the ASGI level before passing requests to FastMCP. 26 | 27 | Compatibility Notes: 28 | =================== 29 | 30 | - Compatible with MCP transport auto-detection (Streamable HTTP → SSE fallback) 31 | - Works with FastMCP's session management and tool execution 32 | - Follows community best practices for FastMCP SSE authentication 33 | - Avoids FastAPI middleware assertion errors during connection cleanup 34 | 35 | Testing: 36 | ======== 37 | 38 | Use with testfiles/sse_auth_test_client.py to verify: 39 | - Transport auto-detection works (405 → SSE fallback) 40 | - Authentication works for all endpoints 41 | - Tools execute successfully with auth verification 42 | - No server-side errors during connection lifecycle 43 | 44 | For production use, consider: 45 | - Proper secret management (not hardcoded JWT secrets) 46 | - Rate limiting and other security measures 47 | - Migration to Streamable HTTP transport (SSE is deprecated) 48 | """ 49 | 50 | import os 51 | from datetime import datetime, timedelta 52 | 53 | import jwt 54 | import uvicorn 55 | from starlette.applications import Starlette 56 | from starlette.routing import Route 57 | from starlette.requests import Request 58 | from starlette.responses import JSONResponse 59 | from starlette.exceptions import HTTPException 60 | from mcp.server import FastMCP 61 | 62 | # JWT Configuration - for testing only, don't use this in production! 63 | JWT_SECRET = "MCP_TEST_SECRET" 64 | JWT_ALGORITHM = "HS512" 65 | JWT_TOKEN_EXPIRY = 60 # in minutes 66 | 67 | # Global variable to store the current request's auth token 68 | # This pattern is commonly used in FastMCP community examples 69 | # and allows tools to access authentication state 70 | auth_token = "" 71 | 72 | # Create MCP application using FastMCP 73 | # Note: No port specified here - we'll run with uvicorn directly 74 | mcp = FastMCP("auth-test-mcp", 75 | description="MCP Authentication Test Server") 76 | 77 | 78 | def create_jwt_token(expiry_minutes=JWT_TOKEN_EXPIRY) -> str: 79 | """ 80 | Create a JWT token for testing authentication. 81 | 82 | Args: 83 | expiry_minutes: Token expiry time in minutes (default: 60) 84 | 85 | Returns: 86 | str: JWT token string 87 | """ 88 | expiration = datetime.utcnow() + timedelta(minutes=expiry_minutes) 89 | payload = { 90 | "sub": "test-client", 91 | "exp": expiration, 92 | "iat": datetime.utcnow(), 93 | } 94 | token = jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM) 95 | return token 96 | 97 | 98 | def verify_jwt(token: str) -> bool: 99 | """ 100 | Verifies the JWT token. 101 | 102 | Args: 103 | token: JWT token string 104 | 105 | Returns: 106 | bool: True if token is valid, False otherwise 107 | """ 108 | try: 109 | jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM]) 110 | return True 111 | except jwt.ExpiredSignatureError: 112 | print("[SERVER] Token expired") 113 | return False 114 | except jwt.InvalidTokenError: 115 | print("[SERVER] Invalid token") 116 | return False 117 | 118 | 119 | def require_auth(): 120 | """ 121 | Authentication check function for MCP tools. 122 | 123 | This function verifies that the current request has a valid JWT token. 124 | It's called from individual MCP tools to enforce authentication. 125 | 126 | The global token storage pattern allows tools to access authentication 127 | state without needing to pass tokens through the MCP protocol. 128 | 129 | Raises: 130 | Exception: If no valid token is present 131 | """ 132 | global auth_token 133 | if not auth_token or not verify_jwt(auth_token): 134 | raise Exception("Authentication required") 135 | 136 | 137 | # MCP Tools Definition 138 | # =================== 139 | # These tools demonstrate how to integrate authentication checks 140 | # with FastMCP tool execution. Each tool calls require_auth() to 141 | # verify the request is authenticated before proceeding. 142 | 143 | @mcp.tool() 144 | async def hello(name: str) -> str: 145 | """ 146 | Simple hello world tool that requires authentication. 147 | 148 | Demonstrates the authentication pattern: 149 | 1. Tool is called via MCP protocol 150 | 2. require_auth() checks the global auth token 151 | 3. If valid, tool logic executes 152 | 4. If invalid, tool fails with authentication error 153 | 154 | Args: 155 | name: Name to greet 156 | 157 | Returns: 158 | str: Greeting message 159 | """ 160 | require_auth() # Verify authentication before proceeding 161 | print(f"[SERVER] Got hello request from {name}") 162 | return f"Hello, {name}! Authentication successful." 163 | 164 | 165 | @mcp.tool() 166 | async def echo(message: str) -> str: 167 | """ 168 | Echo tool that requires authentication. 169 | 170 | Another example of the authentication pattern in MCP tools. 171 | 172 | Args: 173 | message: Message to echo 174 | 175 | Returns: 176 | str: Echoed message 177 | """ 178 | require_auth() # Verify authentication before proceeding 179 | print(f"[SERVER] Got echo request: {message}") 180 | return f"ECHO: {message}" 181 | 182 | 183 | # Authentication Middleware and ASGI Integration 184 | # ============================================== 185 | # This section implements the authentication layer that wraps FastMCP's 186 | # SSE application while maintaining compatibility with MCP transport detection. 187 | 188 | async def auth_middleware(request: Request, call_next): 189 | """ 190 | Authentication middleware using Starlette patterns. 191 | 192 | This middleware handles two critical functions: 193 | 1. MCP transport auto-detection (per MCP specification) 194 | 2. JWT authentication for all SSE endpoints 195 | 196 | Transport Detection: 197 | ------------------ 198 | Per MCP spec, when clients test for Streamable HTTP support by POSTing 199 | an InitializeRequest to the SSE endpoint, servers should return 405 200 | Method Not Allowed to indicate they only support SSE transport. 201 | 202 | Authentication: 203 | -------------- 204 | Extracts JWT tokens from Authorization headers and stores them globally 205 | for FastMCP tools to access. This pattern avoids passing auth through 206 | the MCP protocol itself. 207 | 208 | Args: 209 | request: Starlette request object 210 | call_next: Next handler in the chain 211 | 212 | Returns: 213 | Response from FastMCP or auth error response 214 | """ 215 | global auth_token 216 | 217 | # Handle MCP transport detection per specification 218 | # POST to /sse without session_id = transport detection test 219 | if (request.method == "POST" and 220 | request.url.path == "/sse" and 221 | "session_id" not in request.query_params): 222 | print("[SERVER] POST request to /sse endpoint - returning 405 Method Not Allowed for transport detection") 223 | return JSONResponse( 224 | status_code=405, 225 | content={ 226 | "error": { 227 | "code": "method_not_allowed", 228 | "message": "This server only supports SSE transport. Use GET for SSE connection." 229 | } 230 | }, 231 | headers={"Allow": "GET"} 232 | ) 233 | 234 | # Extract and validate JWT token from Authorization header 235 | auth_header = request.headers.get("Authorization") 236 | if auth_header and auth_header.startswith("Bearer "): 237 | auth_token = auth_header.split(" ")[1] 238 | if verify_jwt(auth_token): 239 | print("[SERVER] Authentication successful") 240 | else: 241 | raise HTTPException(status_code=401, detail="Invalid token") 242 | else: 243 | raise HTTPException(status_code=401, detail="Missing or invalid authorization header") 244 | 245 | # Continue to FastMCP SSE application 246 | response = await call_next(request) 247 | return response 248 | 249 | 250 | # ASGI Application Wrapper 251 | # ======================== 252 | # This class wraps FastMCP's SSE application with authentication middleware 253 | # while maintaining proper ASGI patterns that FastMCP expects. 254 | 255 | class AuthSSEApp: 256 | """ 257 | ASGI application wrapper that adds authentication to FastMCP SSE. 258 | 259 | This wrapper is necessary because: 260 | 1. We need to authenticate requests before they reach FastMCP 261 | 2. We must maintain ASGI compatibility that FastMCP expects 262 | 3. We want to avoid FastAPI middleware conflicts that cause assertion errors 263 | 264 | The wrapper implements the ASGI callable interface and applies 265 | authentication middleware before delegating to FastMCP's SSE app. 266 | """ 267 | 268 | def __init__(self, sse_app): 269 | """ 270 | Initialize the wrapper with FastMCP's SSE application. 271 | 272 | Args: 273 | sse_app: FastMCP's SSE ASGI application 274 | """ 275 | self.sse_app = sse_app 276 | 277 | async def __call__(self, scope, receive, send): 278 | """ 279 | ASGI callable that handles authentication then delegates to FastMCP. 280 | 281 | This method: 282 | 1. Creates a Starlette Request from ASGI scope 283 | 2. Applies authentication middleware 284 | 3. Delegates to FastMCP's SSE app if auth succeeds 285 | 4. Returns auth errors if auth fails 286 | 287 | Args: 288 | scope: ASGI scope dict 289 | receive: ASGI receive callable 290 | send: ASGI send callable 291 | """ 292 | # Create request object for middleware 293 | request = Request(scope, receive) 294 | 295 | async def call_next(request): 296 | # Delegate to FastMCP's SSE application 297 | return await self.sse_app(scope, receive, send) 298 | 299 | try: 300 | # Apply authentication middleware 301 | result = await auth_middleware(request, call_next) 302 | # If middleware returns a response object, send it 303 | if hasattr(result, '__call__'): 304 | await result(scope, receive, send) 305 | return result 306 | except HTTPException as e: 307 | # Handle authentication errors 308 | response = JSONResponse( 309 | status_code=e.status_code, 310 | content={"error": e.detail} 311 | ) 312 | await response(scope, receive, send) 313 | 314 | 315 | # Application Setup 316 | # ================= 317 | # Final assembly of the Starlette application with authenticated FastMCP SSE routes. 318 | 319 | # Get FastMCP's SSE ASGI application 320 | sse_app = mcp.sse_app() 321 | 322 | # Create authenticated wrapper 323 | auth_sse_app = AuthSSEApp(sse_app) 324 | 325 | # Define routes for MCP SSE endpoints 326 | # Both /sse and /messages/ need to go through the same auth wrapper 327 | routes = [ 328 | Route("/sse", auth_sse_app, methods=["GET", "POST"]), 329 | Route("/messages/", auth_sse_app, methods=["POST"]), 330 | ] 331 | 332 | # Create the main Starlette application 333 | # This is what uvicorn will serve 334 | app = Starlette(routes=routes) 335 | 336 | 337 | def print_token_info(): 338 | """ 339 | Print token information to help with testing. 340 | 341 | Generates a sample JWT token and displays connection information 342 | for easy testing with the companion test client. 343 | """ 344 | token = create_jwt_token() 345 | print("\n=== SSE Authentication Test Server ===") 346 | print(f"JWT Secret: {JWT_SECRET}") 347 | print(f"JWT Algorithm: {JWT_ALGORITHM}") 348 | print(f"JWT Expiry: {JWT_TOKEN_EXPIRY} minutes") 349 | print("\nSample Token for Testing:") 350 | print(f"Bearer {token}") 351 | print("\nConnect using:") 352 | print("http://localhost:9000/sse") 353 | print("=====================================\n") 354 | 355 | 356 | if __name__ == "__main__": 357 | # Print helpful information for testing 358 | port = int(os.environ.get("PORT", 9000)) 359 | print_token_info() 360 | 361 | # Run with uvicorn 362 | # Note: We use the Starlette app directly, not FastMCP's built-in server 363 | uvicorn.run(app, host="0.0.0.0", port=port) 364 | -------------------------------------------------------------------------------- /testfiles/streamable_http_oauth_test_client.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Test Client for OAuth 2.1-Compliant Authentication 4 | 5 | This script tests your langchain-mcp-tools library's auth parameter support 6 | with OAuth 2.1-compliant authentication (OAuth 2.0 + PKCE) against the simple OAuth server. 7 | 8 | This demonstrates how your library should work with the auth parameter 9 | that accepts an OAuthClientProvider following OAuth 2.1 security best practices. 10 | 11 | Usage: 12 | # First start the OAuth server: 13 | python simple_oauth_server.py 14 | 15 | # Then run this test client: 16 | python test_oauth_client.py 17 | """ 18 | 19 | import asyncio 20 | import logging 21 | import threading 22 | import time 23 | import webbrowser 24 | from http.server import BaseHTTPRequestHandler, HTTPServer 25 | from urllib.parse import parse_qs, urlparse 26 | 27 | from mcp.client.auth import OAuthClientProvider, TokenStorage 28 | from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthToken 29 | from langchain_mcp_tools import convert_mcp_to_langchain_tools 30 | 31 | # Configure logging 32 | logging.basicConfig(level=logging.INFO) 33 | 34 | class InMemoryTokenStorage(TokenStorage): 35 | """Simple in-memory token storage implementation.""" 36 | 37 | def __init__(self): 38 | self._tokens: OAuthToken | None = None 39 | self._client_info: OAuthClientInformationFull | None = None 40 | 41 | async def get_tokens(self) -> OAuthToken | None: 42 | return self._tokens 43 | 44 | async def set_tokens(self, tokens: OAuthToken) -> None: 45 | self._tokens = tokens 46 | 47 | async def get_client_info(self) -> OAuthClientInformationFull | None: 48 | return self._client_info 49 | 50 | async def set_client_info(self, client_info: OAuthClientInformationFull) -> None: 51 | self._client_info = client_info 52 | 53 | class CallbackHandler(BaseHTTPRequestHandler): 54 | """Simple HTTP handler to capture OAuth callback.""" 55 | 56 | def __init__(self, request, client_address, server, callback_data): 57 | """Initialize with callback data storage.""" 58 | self.callback_data = callback_data 59 | super().__init__(request, client_address, server) 60 | 61 | def do_GET(self): 62 | """Handle GET request from OAuth redirect.""" 63 | parsed = urlparse(self.path) 64 | query_params = parse_qs(parsed.query) 65 | 66 | if "code" in query_params: 67 | self.callback_data["authorization_code"] = query_params["code"][0] 68 | self.callback_data["state"] = query_params.get("state", [None])[0] 69 | self.send_response(200) 70 | self.send_header("Content-type", "text/html") 71 | self.end_headers() 72 | self.wfile.write(b""" 73 | 74 | 75 |

Authorization Successful!

76 |

OAuth flow completed successfully.

77 |

You can close this window and return to the terminal.

78 | 79 | 80 | 81 | """) 82 | elif "error" in query_params: 83 | self.callback_data["error"] = query_params["error"][0] 84 | self.send_response(400) 85 | self.send_header("Content-type", "text/html") 86 | self.end_headers() 87 | self.wfile.write( 88 | f""" 89 | 90 | 91 |

Authorization Failed

92 |

Error: {query_params['error'][0]}

93 |

You can close this window and return to the terminal.

94 | 95 | 96 | """.encode() 97 | ) 98 | else: 99 | self.send_response(404) 100 | self.end_headers() 101 | 102 | def log_message(self, format, *args): 103 | """Suppress default logging.""" 104 | pass 105 | 106 | class CallbackServer: 107 | """Simple server to handle OAuth callbacks.""" 108 | 109 | def __init__(self, port=3000): 110 | self.port = port 111 | self.server = None 112 | self.thread = None 113 | self.callback_data = {"authorization_code": None, "state": None, "error": None} 114 | 115 | def _create_handler_with_data(self): 116 | """Create a handler class with access to callback data.""" 117 | callback_data = self.callback_data 118 | 119 | class DataCallbackHandler(CallbackHandler): 120 | def __init__(self, request, client_address, server): 121 | super().__init__(request, client_address, server, callback_data) 122 | 123 | return DataCallbackHandler 124 | 125 | def start(self): 126 | """Start the callback server in a background thread.""" 127 | handler_class = self._create_handler_with_data() 128 | self.server = HTTPServer(("localhost", self.port), handler_class) 129 | self.thread = threading.Thread(target=self.server.serve_forever, daemon=True) 130 | self.thread.start() 131 | print(f"🖥️ Started OAuth callback server on http://localhost:{self.port}") 132 | 133 | def stop(self): 134 | """Stop the callback server.""" 135 | if self.server: 136 | self.server.shutdown() 137 | self.server.server_close() 138 | if self.thread: 139 | self.thread.join(timeout=1) 140 | 141 | def wait_for_callback(self, timeout=300): 142 | """Wait for OAuth callback with timeout.""" 143 | start_time = time.time() 144 | while time.time() - start_time < timeout: 145 | if self.callback_data["authorization_code"]: 146 | return self.callback_data["authorization_code"] 147 | elif self.callback_data["error"]: 148 | raise Exception(f"OAuth error: {self.callback_data['error']}") 149 | time.sleep(0.1) 150 | raise Exception("Timeout waiting for OAuth callback") 151 | 152 | def get_state(self): 153 | """Get the received state parameter.""" 154 | return self.callback_data["state"] 155 | 156 | async def test_oauth_authentication(): 157 | """Test OAuth 2.1-compliant authentication with your library.""" 158 | print("🔐 Testing OAuth 2.1-Compliant Authentication with langchain-mcp-tools") 159 | print("=" * 70) 160 | 161 | # Set up callback server 162 | callback_server = CallbackServer(port=3000) 163 | callback_server.start() 164 | 165 | try: 166 | async def callback_handler() -> tuple[str, str | None]: 167 | """Wait for OAuth callback and return auth code and state.""" 168 | print("⏳ Waiting for OAuth authorization callback...") 169 | try: 170 | auth_code = callback_server.wait_for_callback(timeout=300) 171 | return auth_code, callback_server.get_state() 172 | finally: 173 | pass # Don't stop server here, let finally block handle it 174 | 175 | async def redirect_handler(authorization_url: str) -> None: 176 | """Redirect handler that opens the URL in a browser.""" 177 | print(f"🌐 Opening browser for OAuth authorization...") 178 | print(f" URL: {authorization_url}") 179 | webbrowser.open(authorization_url) 180 | 181 | # Create OAuth client metadata 182 | client_metadata = OAuthClientMetadata( 183 | client_name="Test MCP Client", 184 | redirect_uris=["http://localhost:3000/callback"], 185 | grant_types=["authorization_code", "refresh_token"], 186 | response_types=["code"], 187 | token_endpoint_auth_method="client_secret_post", 188 | ) 189 | 190 | # Create OAuth authentication provider 191 | oauth_auth = OAuthClientProvider( 192 | server_url="http://localhost:8003", 193 | client_metadata=client_metadata, 194 | storage=InMemoryTokenStorage(), 195 | redirect_handler=redirect_handler, 196 | callback_handler=callback_handler, 197 | ) 198 | 199 | # Test configuration with OAuth auth 200 | oauth_config = { 201 | "oauth-server": { 202 | "url": "http://127.0.0.1:8003/mcp", 203 | "auth": oauth_auth, # This should be supported by your library 204 | "timeout": 30.0 205 | } 206 | } 207 | 208 | print("\n🚀 Starting OAuth flow...") 209 | print("💡 A browser window will open for authorization") 210 | print("💡 Complete the OAuth flow in the browser") 211 | 212 | tools, cleanup = await convert_mcp_to_langchain_tools(oauth_config) 213 | 214 | print(f"\n✅ OAuth authentication successful!") 215 | print(f"🛠️ Connected with {len(tools)} tools available") 216 | 217 | # List available tools 218 | print("\n🔧 Available Tools:") 219 | for tool in tools: 220 | print(f" • {tool.name}: {tool.description}") 221 | 222 | # Test a tool 223 | if tools: 224 | print("\n🧪 Testing tool execution...") 225 | user_tool = next((t for t in tools if 'current_user' in t.name), None) 226 | if user_tool: 227 | result = await user_tool.ainvoke({}) 228 | print(f"🔧 Tool result: {result}") 229 | 230 | # Test another tool with parameters 231 | create_tool = next((t for t in tools if 'create_document' in t.name), None) 232 | if create_tool: 233 | result = await create_tool.ainvoke({ 234 | "title": "OAuth Test Document", 235 | "content": "This document was created via OAuth-authenticated MCP tool call!" 236 | }) 237 | print(f"🔧 Create tool result: {result}") 238 | 239 | await cleanup() 240 | print("\n✅ OAuth test completed successfully!") 241 | 242 | except Exception as e: 243 | print(f"\n❌ OAuth test failed: {e}") 244 | import traceback 245 | traceback.print_exc() 246 | finally: 247 | callback_server.stop() 248 | 249 | async def test_oauth_error_scenarios(): 250 | """Test OAuth error scenarios.""" 251 | print("\n⚠️ Testing OAuth Error Scenarios") 252 | print("=" * 50) 253 | 254 | # Test 1: Invalid server URL 255 | print("\n🧪 Test 1: Invalid OAuth server URL") 256 | try: 257 | oauth_auth = OAuthClientProvider( 258 | server_url="http://localhost:9999", # Non-existent server 259 | client_metadata=OAuthClientMetadata( 260 | client_name="Test Client", 261 | redirect_uris=["http://localhost:3000/callback"], 262 | grant_types=["authorization_code"], 263 | response_types=["code"], 264 | ), 265 | storage=InMemoryTokenStorage(), 266 | redirect_handler=lambda url: None, 267 | callback_handler=lambda: ("invalid", None), 268 | ) 269 | 270 | config = { 271 | "invalid-oauth": { 272 | "url": "http://127.0.0.1:9999/mcp", 273 | "auth": oauth_auth, 274 | "timeout": 5.0 275 | } 276 | } 277 | 278 | tools, cleanup = await convert_mcp_to_langchain_tools(config) 279 | print(f"❌ Unexpected success: {len(tools)} tools (should have failed)") 280 | await cleanup() 281 | 282 | except Exception as e: 283 | print(f"✅ Expected failure: {str(e)[:100]}...") 284 | 285 | async def test_mixed_auth_with_oauth(): 286 | """Test mixed authentication including OAuth.""" 287 | print("\n🔀 Testing Mixed Authentication (OAuth + Headers)") 288 | print("=" * 60) 289 | 290 | # This test would require multiple servers running 291 | # For now, just test the configuration structure 292 | print("💡 This test demonstrates configuration structure for mixed auth:") 293 | 294 | example_config = { 295 | # OAuth server (would need OAuth flow) 296 | "oauth-server": { 297 | "url": "http://127.0.0.1:8003/mcp", 298 | # "auth": oauth_auth, # Commented out to avoid triggering OAuth flow 299 | "timeout": 30.0 300 | }, 301 | # Bearer token server 302 | "bearer-server": { 303 | "url": "http://127.0.0.1:8001/mcp", 304 | "headers": {"Authorization": "Bearer valid-token-123"}, 305 | "timeout": 10.0 306 | }, 307 | # API key server 308 | "api-key-server": { 309 | "url": "http://127.0.0.1:8002/mcp", 310 | "headers": {"X-API-Key": "sk-test-key-123"}, 311 | "timeout": 10.0 312 | } 313 | } 314 | 315 | print("✅ Mixed auth configuration structure validated") 316 | print("📋 This shows your library should support:") 317 | print(" • OAuth via 'auth' parameter") 318 | print(" • Bearer tokens via 'headers'") 319 | print(" • API keys via 'headers'") 320 | print(" • Multiple auth methods in one config") 321 | 322 | async def main(): 323 | """Run all OAuth 2.1-compliant tests.""" 324 | print("🧪 OAuth 2.1-Compliant Authentication Tests for langchain-mcp-tools") 325 | print("=" * 80) 326 | print("Prerequisites:") 327 | print(" • simple_oauth_server.py (OAuth 2.1-compliant) running on port 8003") 328 | print("=" * 80) 329 | 330 | await test_oauth_authentication() 331 | await test_oauth_error_scenarios() 332 | await test_mixed_auth_with_oauth() 333 | 334 | print("\n🎉 All OAuth Tests Completed!") 335 | print("\n📊 Summary of what was tested:") 336 | print(" ✅ OAuth 2.1-compliant authorization code flow with PKCE") 337 | print(" ✅ OAuth client provider integration") 338 | print(" ✅ Browser-based authorization flow") 339 | print(" ✅ Access token usage for MCP requests") 340 | print(" ✅ Tool execution with OAuth authentication") 341 | print(" ✅ Error handling for invalid OAuth configs") 342 | print("\n💡 Key validation points:") 343 | print(" • 'auth' parameter accepts OAuthClientProvider") 344 | print(" • OAuth flow completes successfully") 345 | print(" • Access tokens are used for MCP requests") 346 | print(" • OAuth works alongside other auth methods") 347 | print(" • Error scenarios are handled gracefully") 348 | 349 | if __name__ == "__main__": 350 | asyncio.run(main()) 351 | -------------------------------------------------------------------------------- /testfiles/streamable_http_oauth_test_server.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Simple OAuth MCP Test Server 4 | 5 | This server implements a minimal OAuth 2.1-compliant authorization server for testing 6 | your langchain-mcp-tools library's auth parameter support. 7 | 8 | This implementation follows OAuth 2.1 security best practices including: 9 | - PKCE (Proof Key for Code Exchange) support 10 | - Short-lived access tokens with refresh tokens 11 | - Secure authorization code flow only 12 | - No deprecated grant types (implicit, password) 13 | 14 | This is a simplified version that focuses on testing the OAuth flow 15 | rather than implementing a production-ready OAuth server. 16 | 17 | Usage: 18 | python simple_oauth_server.py 19 | 20 | Test with: 21 | python test_oauth_client.py 22 | """ 23 | 24 | import secrets 25 | import time 26 | import uvicorn 27 | from typing import Any, Dict 28 | from fastapi import FastAPI, Request, HTTPException, Form 29 | from fastapi.responses import RedirectResponse, JSONResponse, HTMLResponse 30 | from urllib.parse import urlencode, parse_qs 31 | from mcp.server.fastmcp import FastMCP 32 | 33 | # In-memory storage for simplicity (production would use a database) 34 | clients: Dict[str, Dict[str, Any]] = {} 35 | authorization_codes: Dict[str, Dict[str, Any]] = {} 36 | access_tokens: Dict[str, Dict[str, Any]] = {} 37 | 38 | # Pre-register a test client 39 | TEST_CLIENT = { 40 | "client_id": "test-mcp-client-123", 41 | "client_secret": "secret-456", 42 | "redirect_uris": ["http://localhost:3000/callback"], 43 | "grant_types": ["authorization_code", "refresh_token"], 44 | "response_types": ["code"], 45 | "scopes": ["read", "write"] 46 | } 47 | clients[TEST_CLIENT["client_id"]] = TEST_CLIENT 48 | 49 | # Create FastAPI app 50 | app = FastAPI(title="Simple OAuth MCP Test Server") 51 | 52 | # Create MCP server (stateless) 53 | mcp = FastMCP( 54 | name="OAuthTestServer", 55 | stateless_http=True, 56 | json_response=True 57 | ) 58 | 59 | @mcp.tool(description="Get authenticated user information") 60 | def get_current_user() -> str: 61 | """Get information about the currently authenticated user.""" 62 | return "Authenticated user: test-user@example.com (OAuth verified)" 63 | 64 | @mcp.tool(description="List user's documents") 65 | def list_user_documents() -> str: 66 | """List documents accessible to the authenticated user.""" 67 | return "User documents: document1.pdf, document2.txt, report.xlsx (OAuth authenticated)" 68 | 69 | @mcp.tool(description="Create a new document") 70 | def create_document(title: str, content: str) -> str: 71 | """Create a new document for the authenticated user.""" 72 | return f"Created document '{title}' with content: {content[:50]}... (OAuth authenticated)" 73 | 74 | @mcp.resource("user://profile") 75 | def get_user_profile() -> str: 76 | """Get user profile information.""" 77 | return "User profile: John Doe, john@example.com, Premium Account (OAuth authenticated)" 78 | 79 | # OAuth 2.1-Compliant Authorization Server Endpoints 80 | 81 | @app.get("/.well-known/oauth-authorization-server") 82 | async def authorization_server_metadata(): 83 | """OAuth 2.1-compliant Authorization Server Metadata (RFC 8414). 84 | 85 | This metadata indicates OAuth 2.1 compliance through: 86 | - PKCE support (code_challenge_methods_supported: S256) 87 | - Secure grant types only (no implicit, password grants) 88 | - Authorization code flow with refresh tokens 89 | - Dynamic client registration (RFC 7591) 90 | """ 91 | return { 92 | "issuer": "http://localhost:8003", 93 | "authorization_endpoint": "http://localhost:8003/authorize", 94 | "token_endpoint": "http://localhost:8003/token", 95 | "registration_endpoint": "http://localhost:8003/register", # RFC 7591: Dynamic Client Registration 96 | "response_types_supported": ["code"], # OAuth 2.1: code flow only 97 | "grant_types_supported": ["authorization_code", "refresh_token"], # OAuth 2.1: secure grants only 98 | "code_challenge_methods_supported": ["S256"], # OAuth 2.1: PKCE support 99 | "scopes_supported": ["read", "write"], 100 | "token_endpoint_auth_methods_supported": ["client_secret_post"] 101 | } 102 | 103 | @app.get("/authorize") 104 | async def authorize( 105 | response_type: str, 106 | client_id: str, 107 | redirect_uri: str, 108 | scope: str = "", 109 | state: str = "", 110 | code_challenge: str = "", 111 | code_challenge_method: str = "" 112 | ): 113 | """OAuth authorization endpoint.""" 114 | # Validate client 115 | client = clients.get(client_id) 116 | if not client: 117 | raise HTTPException(status_code=400, detail="Invalid client_id") 118 | 119 | # Validate redirect URI 120 | if redirect_uri not in client["redirect_uris"]: 121 | raise HTTPException(status_code=400, detail="Invalid redirect_uri") 122 | 123 | # For testing, auto-approve the authorization 124 | # In production, this would show a consent screen 125 | auth_code = f"code_{secrets.token_hex(16)}" 126 | 127 | # Store authorization code 128 | authorization_codes[auth_code] = { 129 | "client_id": client_id, 130 | "redirect_uri": redirect_uri, 131 | "scope": scope, 132 | "code_challenge": code_challenge, 133 | "code_challenge_method": code_challenge_method, 134 | "expires_at": time.time() + 600, # 10 minutes 135 | "used": False 136 | } 137 | 138 | # Redirect back to client with code 139 | params = {"code": auth_code} 140 | if state: 141 | params["state"] = state 142 | 143 | redirect_url = f"{redirect_uri}?{urlencode(params)}" 144 | return RedirectResponse(url=redirect_url) 145 | 146 | @app.post("/register") 147 | async def register_client(request: Request): 148 | """OAuth 2.0 Dynamic Client Registration (RFC 7591). 149 | 150 | This endpoint allows OAuth clients to register themselves dynamically 151 | without requiring pre-configuration. This is commonly used by OAuth 152 | client libraries like the MCP Python SDK. 153 | """ 154 | try: 155 | data = await request.json() 156 | 157 | # Generate unique client credentials 158 | client_id = f"dynamic-client-{secrets.token_hex(8)}" 159 | client_secret = secrets.token_urlsafe(32) 160 | 161 | # Validate required fields 162 | redirect_uris = data.get("redirect_uris", []) 163 | if not redirect_uris: 164 | raise HTTPException(status_code=400, detail="redirect_uris is required") 165 | 166 | # Create client configuration 167 | client_info = { 168 | "client_id": client_id, 169 | "client_secret": client_secret, 170 | "redirect_uris": redirect_uris, 171 | "grant_types": data.get("grant_types", ["authorization_code"]), 172 | "response_types": data.get("response_types", ["code"]), 173 | "scopes": data.get("scope", "read write").split() if isinstance(data.get("scope"), str) else data.get("scope", ["read", "write"]), 174 | "client_name": data.get("client_name", "Dynamic MCP Client"), 175 | "token_endpoint_auth_method": data.get("token_endpoint_auth_method", "client_secret_post") 176 | } 177 | 178 | # Store the dynamically registered client 179 | clients[client_id] = client_info 180 | 181 | print(f"🔧 Dynamically registered new client: {client_id}") 182 | print(f" Name: {client_info['client_name']}") 183 | print(f" Redirect URIs: {redirect_uris}") 184 | 185 | # Return client registration response (RFC 7591) 186 | return { 187 | "client_id": client_id, 188 | "client_secret": client_secret, 189 | "redirect_uris": client_info["redirect_uris"], 190 | "grant_types": client_info["grant_types"], 191 | "response_types": client_info["response_types"], 192 | "scope": " ".join(client_info["scopes"]), 193 | "token_endpoint_auth_method": client_info["token_endpoint_auth_method"], 194 | "client_name": client_info["client_name"] 195 | } 196 | 197 | except ValueError as e: 198 | raise HTTPException(status_code=400, detail=f"Invalid JSON: {e}") 199 | except Exception as e: 200 | print(f"❌ Client registration error: {e}") 201 | raise HTTPException(status_code=400, detail=f"Registration failed: {e}") 202 | 203 | @app.post("/token") 204 | async def token_endpoint( 205 | grant_type: str = Form(...), 206 | client_id: str = Form(...), 207 | client_secret: str = Form(...), 208 | code: str = Form(None), 209 | redirect_uri: str = Form(None), 210 | code_verifier: str = Form(None) 211 | ): 212 | """OAuth token endpoint.""" 213 | # Validate client credentials 214 | client = clients.get(client_id) 215 | if not client or client["client_secret"] != client_secret: 216 | raise HTTPException(status_code=401, detail="Invalid client credentials") 217 | 218 | if grant_type == "authorization_code": 219 | # Validate authorization code 220 | auth_code_data = authorization_codes.get(code) 221 | if not auth_code_data: 222 | raise HTTPException(status_code=400, detail="Invalid authorization code") 223 | 224 | if auth_code_data["used"]: 225 | raise HTTPException(status_code=400, detail="Authorization code already used") 226 | 227 | if auth_code_data["expires_at"] < time.time(): 228 | raise HTTPException(status_code=400, detail="Authorization code expired") 229 | 230 | if auth_code_data["client_id"] != client_id: 231 | raise HTTPException(status_code=400, detail="Client mismatch") 232 | 233 | # Mark code as used 234 | auth_code_data["used"] = True 235 | 236 | # Generate access token 237 | access_token = f"token_{secrets.token_hex(32)}" 238 | refresh_token = f"refresh_{secrets.token_hex(32)}" 239 | 240 | # Store tokens 241 | access_tokens[access_token] = { 242 | "client_id": client_id, 243 | "scope": auth_code_data["scope"], 244 | "expires_at": time.time() + 3600, # 1 hour 245 | "token_type": "Bearer" 246 | } 247 | 248 | return { 249 | "access_token": access_token, 250 | "token_type": "Bearer", 251 | "expires_in": 3600, 252 | "refresh_token": refresh_token, 253 | "scope": auth_code_data["scope"] 254 | } 255 | 256 | else: 257 | raise HTTPException(status_code=400, detail="Unsupported grant type") 258 | 259 | # Authentication middleware for MCP endpoints 260 | @app.middleware("http") 261 | async def oauth_auth_middleware(request: Request, call_next): 262 | """Apply OAuth authentication to MCP endpoints.""" 263 | if request.url.path.startswith("/mcp"): 264 | # Check for Authorization header 265 | auth_header = request.headers.get("authorization") 266 | if not auth_header or not auth_header.startswith("Bearer "): 267 | return JSONResponse( 268 | status_code=401, 269 | content={"error": "invalid_token", "error_description": "Missing or invalid access token"}, 270 | headers={"WWW-Authenticate": "Bearer"}, 271 | ) 272 | 273 | # Extract and validate token 274 | token = auth_header.replace("Bearer ", "") 275 | token_data = access_tokens.get(token) 276 | if not token_data: 277 | return JSONResponse( 278 | status_code=401, 279 | content={"error": "invalid_token", "error_description": "Invalid access token"}, 280 | headers={"WWW-Authenticate": "Bearer"}, 281 | ) 282 | 283 | # Check if token expired 284 | if token_data["expires_at"] < time.time(): 285 | return JSONResponse( 286 | status_code=401, 287 | content={"error": "invalid_token", "error_description": "Access token expired"}, 288 | headers={"WWW-Authenticate": "Bearer"}, 289 | ) 290 | 291 | response = await call_next(request) 292 | return response 293 | 294 | # Mount the MCP app 295 | app.mount("/mcp", mcp.streamable_http_app()) 296 | 297 | # Info endpoints 298 | @app.get("/") 299 | async def root(): 300 | """Server information.""" 301 | return { 302 | "name": "OAuth 2.1-compliant MCP Test Server", 303 | "oauth_endpoints": { 304 | "authorization": "/authorize", 305 | "token": "/token", 306 | "registration": "/register", 307 | "metadata": "/.well-known/oauth-authorization-server" 308 | }, 309 | "mcp_endpoint": "/mcp", 310 | "test_client": { 311 | "client_id": TEST_CLIENT["client_id"], 312 | "client_secret": TEST_CLIENT["client_secret"], 313 | "redirect_uris": TEST_CLIENT["redirect_uris"] 314 | } 315 | } 316 | 317 | @app.get("/health") 318 | async def health_check(): 319 | """Health check endpoint.""" 320 | return {"status": "healthy", "auth": "oauth2"} 321 | 322 | if __name__ == "__main__": 323 | print("🚀 Starting OAuth 2.1-Compliant MCP Test Server") 324 | print("🔐 Authentication: OAuth 2.1-compliant (OAuth 2.0 + PKCE)") 325 | print("🔒 Security Features: PKCE required, secure grants only, short-lived tokens") 326 | print("🔗 MCP Endpoint: http://localhost:8003/mcp") 327 | print("🔑 OAuth Endpoints:") 328 | print(" • Authorization: http://localhost:8003/authorize") 329 | print(" • Token: http://localhost:8003/token") 330 | print(" • Registration: http://localhost:8003/register (Dynamic Client Registration)") 331 | print(" • Metadata: http://localhost:8003/.well-known/oauth-authorization-server") 332 | print("-" * 70) 333 | print("🧪 Test Client Credentials:") 334 | print(f" • Client ID: {TEST_CLIENT['client_id']}") 335 | print(f" • Client Secret: {TEST_CLIENT['client_secret']}") 336 | print(f" • Redirect URI: {TEST_CLIENT['redirect_uris'][0]}") 337 | print("-" * 70) 338 | print("🛠️ Tools available: get_current_user, list_user_documents, create_document") 339 | print("📦 Resources available: user://profile") 340 | print("💡 Use Ctrl+C to stop the server") 341 | print("-" * 70) 342 | 343 | uvicorn.run( 344 | app, 345 | host="127.0.0.1", 346 | port=8003, 347 | log_level="info" 348 | ) 349 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # MCP to LangChain Tools Conversion Library / Python [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://github.com/hideya/langchain-mcp-tools-py/blob/main/LICENSE) [![pypi version](https://img.shields.io/pypi/v/langchain-mcp-tools.svg)](https://pypi.org/project/langchain-mcp-tools/) [![network dependents](https://dependents.info/hideya/langchain-mcp-tools-py/badge)](https://dependents.info/hideya/langchain-mcp-tools-py) 2 | 3 | A simple, lightweight library to use 4 | [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) 5 | server tools from LangChain. 6 | 7 | langchain-mcp-tools-diagram 8 | 9 | Its simplicity and extra features for local MCP servers can make it useful as a basis for your own customizations. 10 | However, it only supports text results of tool calls and does not support MCP features other than tools. 11 | 12 | [LangChain's **official LangChain MCP Adapters** library](https://pypi.org/project/langchain-mcp-adapters/), 13 | which supports comprehensive integration with LangChain, has been released. 14 | You may want to consider using it if you don't have specific needs for this library. 15 | 16 | ## Prerequisites 17 | 18 | - Python 3.11+ 19 | 20 | ## Installation 21 | 22 | ```bash 23 | pip install langchain-mcp-tools 24 | ``` 25 | 26 | ## Quick Start 27 | 28 | `convert_mcp_to_langchain_tools()` utility function accepts MCP server configurations 29 | that follow the same structure as 30 | [Claude for Desktop](https://modelcontextprotocol.io/quickstart/user), 31 | but only the contents of the `mcpServers` property, 32 | and is expressed as a `dict`, e.g.: 33 | 34 | ```python 35 | mcp_servers = { 36 | "filesystem": { 37 | "command": "npx", 38 | "args": ["-y", "@modelcontextprotocol/server-filesystem", "."] 39 | }, 40 | "fetch": { 41 | "command": "uvx", 42 | "args": ["mcp-server-fetch"] 43 | }, 44 | "github": { 45 | "type": "http", 46 | "url": "https://api.githubcopilot.com/mcp/", 47 | "headers": { 48 | "Authorization": f"Bearer {os.environ.get('GITHUB_PERSONAL_ACCESS_TOKEN')}" 49 | } 50 | }, 51 | "notion": { # For MCP servers that require OAuth, consider using "mcp-remote" 52 | "command": "npx", 53 | "args": ["-y", "mcp-remote", "https://mcp.notion.com/mcp"], 54 | }, 55 | } 56 | 57 | tools, cleanup = await convert_mcp_to_langchain_tools( 58 | mcp_servers 59 | ) 60 | ``` 61 | 62 | This utility function initializes all specified MCP servers in parallel, 63 | and returns LangChain Tools 64 | ([`tools: list[BaseTool]`](https://python.langchain.com/api_reference/core/tools/langchain_core.tools.base.BaseTool.html#langchain_core.tools.base.BaseTool)) 65 | by gathering available MCP tools from the servers, 66 | and by wrapping them into LangChain tools. 67 | It also returns an async callback function (`cleanup: McpServerCleanupFn`) 68 | to be invoked to close all MCP server sessions when finished. 69 | 70 | The returned tools can be used with LangChain, e.g.: 71 | 72 | ```python 73 | # from langchain.chat_models import init_chat_model 74 | llm = init_chat_model("google_genai:gemini-2.5-flash") 75 | 76 | # from langgraph.prebuilt import create_react_agent 77 | agent = create_react_agent( 78 | llm, 79 | tools 80 | ) 81 | ``` 82 | 83 | The returned `cleanup` function properly handles resource cleanup: 84 | 85 | - Closes all MCP server connections concurrently and logs any cleanup failures 86 | - Continues cleanup of remaining servers even if some fail 87 | - Should always be called when done using the tools 88 | 89 | It is typically invoked in a finally block: 90 | 91 | ```python 92 | try: 93 | tools, cleanup = await convert_mcp_to_langchain_tools(mcp_servers) 94 | 95 | # Use tools with your LLM 96 | 97 | finally: 98 | # cleanup can be undefined when an exeption occurs during initialization 99 | if "cleanup" in locals(): 100 | await cleanup() 101 | ``` 102 | 103 | A minimal but complete working usage example can be found 104 | [in this example in the langchain-mcp-tools-py-usage repo](https://github.com/hideya/langchain-mcp-tools-py-usage/blob/main/src/example.py) 105 | 106 | For hands-on experimentation with MCP server integration, 107 | try [this MCP Client CLI tool built with this library](https://pypi.org/project/mcp-chat/) 108 | 109 | A TypeScript equivalent of this utility is available 110 | [here](https://www.npmjs.com/package/@h1deya/langchain-mcp-tools) 111 | 112 | ## Introduction 113 | 114 | This package is intended to simplify the use of 115 | [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) 116 | server tools with LangChain / Python. 117 | 118 | [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) is the de facto industry standard 119 | that dramatically expands the scope of LLMs by enabling the integration of external tools and resources, 120 | including DBs, Cloud Storages, GitHub, Docker, Slack, and more. 121 | There are quite a few useful MCP servers already available. 122 | See [MCP Server Listing on the Official Site](https://github.com/modelcontextprotocol/servers?tab=readme-ov-file#model-context-protocol-servers). 123 | 124 | This utility's goal is to make these numerous MCP servers easily accessible from LangChain. 125 | It contains a utility function `convert_mcp_to_langchain_tools()`. 126 | This async function handles parallel initialization of specified multiple MCP servers 127 | and converts their available tools into a list of LangChain-compatible tools. 128 | 129 | For detailed information on how to use this library, please refer to the following document: 130 | ["Supercharging LangChain: Integrating 2000+ MCP with ReAct"](https://medium.com/@h1deya/supercharging-langchain-integrating-450-mcp-with-react-d4e467cbf41a). 131 | 132 | ## MCP Protocol Support 133 | 134 | This library supports **MCP Protocol version 2025-03-26** and maintains backwards compatibility with version 2024-11-05. 135 | It follows the [official MCP specification](https://modelcontextprotocol.io/specification/2025-03-26/) for transport selection and backwards compatibility. 136 | 137 | ### Limitations 138 | 139 | - **Tool Return Types**: Currently, only text results of tool calls are supported. 140 | The library uses LangChain's `response_format: 'content'` (the default), which only supports text strings. 141 | While MCP tools can return multiple content types (text, images, etc.), this library currently filters and uses only text content. 142 | - **MCP Features**: Only MCP [Tools](https://modelcontextprotocol.io/docs/concepts/tools) are supported. Other MCP features like Resources, Prompts, and Sampling are not implemented. 143 | 144 | ### Note 145 | 146 | - **Passing PATH Env Variable**: The library automatically adds the `PATH` environment variable to stdio server configrations if not explicitly provided to ensure servers can find required executables. 147 | 148 | ## Features 149 | 150 | ### stderr Redirection for Local MCP Server 151 | 152 | A new key `"errlog"` has been introduced to specify a file-like object 153 | to which local (stdio) MCP server's stderr is redirected. 154 | 155 | ```python 156 | log_path = f"mcp-server-{server_name}.log" 157 | log_file = open(log_path, "w") 158 | mcp_servers[server_name]["errlog"] = log_file 159 | ``` 160 | 161 | A usage example can be found [here](https://github.com/hideya/langchain-mcp-tools-py-usage/blob/3bd35d9fb49f4b631fe3d0cc8491d43cbf69693b/src/example.py#L88-L108). 162 | The key name `errlog` is derived from 163 | [`stdio_client()`'s argument `errlog`](https://github.com/modelcontextprotocol/python-sdk/blob/babb477dffa33f46cdc886bc885eb1d521151430/src/mcp/client/stdio/__init__.py#L96). 164 | 165 | ### Working Directory Configuration for Local MCP Servers 166 | 167 | The working directory that is used when spawning a local (stdio) MCP server 168 | can be specified with the `"cwd"` key as follows: 169 | 170 | ```python 171 | "local-server-name": { 172 | "command": "...", 173 | "args": [...], 174 | "cwd": "/working/directory" # the working dir to be use by the server 175 | }, 176 | ``` 177 | 178 | The key name `cwd` is derived from 179 | Python SDK's [`StdioServerParameters`](https://github.com/modelcontextprotocol/python-sdk/blob/babb477dffa33f46cdc886bc885eb1d521151430/src/mcp/client/stdio/__init__.py#L76-L77). 180 | 181 | ### Transport Selection Priority 182 | 183 | The library selects transports using the following priority order: 184 | 185 | 1. **Explicit transport/type field** (must match URL protocol if URL provided) 186 | 2. **URL protocol auto-detection** (http/https → StreamableHTTP → SSE, ws/wss → WebSocket) 187 | 3. **Command presence** → Stdio transport 188 | 4. **Error** if none of the above match 189 | 190 | This ensures predictable behavior while allowing flexibility for different deployment scenarios. 191 | 192 | ### Remote MCP Server Support 193 | 194 | `mcp_servers` configuration for Streamable HTTP, SSE (Server-Sent Events) and Websocket servers are as follows: 195 | 196 | ```py 197 | # Auto-detection: tries Streamable HTTP first, falls back to SSE on 4xx errors 198 | "auto-detect-server": { 199 | "url": f"http://{server_host}:{server_port}/..." 200 | }, 201 | 202 | # Explicit Streamable HTTP 203 | "streamable-http-server": { 204 | "url": f"http://{server_host}:{server_port}/...", 205 | "transport": "streamable_http" 206 | # "type": "http" # VSCode-style config also works instead of the above 207 | }, 208 | 209 | # Explicit SSE 210 | "sse-server-name": { 211 | "url": f"http://{sse_server_host}:{sse_server_port}/...", 212 | "transport": "sse" # or `"type": "sse"` 213 | }, 214 | 215 | # WebSocket 216 | "ws-server-name": { 217 | "url": f"ws://${ws_server_host}:${ws_server_port}/..." 218 | # optionally `"transport": "ws"` or `"type": "ws"` 219 | }, 220 | ``` 221 | 222 | The `"headers"` key can be used to pass HTTP headers to Streamable HTTP and SSE connection. 223 | 224 | ```py 225 | "github": { 226 | "type": "http", 227 | "url": "https://api.githubcopilot.com/mcp/", 228 | "headers": { 229 | "Authorization": f"Bearer {os.environ.get('GITHUB_PERSONAL_ACCESS_TOKEN')}" 230 | } 231 | }, 232 | ``` 233 | 234 | NOTE: When accessing the GitHub MCP server, [GitHub PAT (Personal Access Token)](https://github.com/settings/personal-access-tokens) 235 | alone is not enough; your GitHub account must have an active Copilot subscription or be assigned a Copilot license through your organization. 236 | 237 | **Auto-detection behavior (default):** 238 | - For HTTP/HTTPS URLs without explicit `transport`, the library follows [MCP specification recommendations](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#backwards-compatibility) 239 | - First attempts Streamable HTTP transport 240 | - If Streamable HTTP fails with a 4xx error, automatically falls back to SSE transport 241 | - Non-4xx errors (network issues, etc.) are re-thrown without fallback 242 | 243 | **Explicit transport selection:** 244 | - Set `"transport": "streamable_http"` (or VSCode-style config `"type": "http"`) to force Streamable HTTP (no fallback) 245 | - Set `"transport": "sse"` to force SSE transport 246 | - WebSocket URLs (`ws://` or `wss://`) always use WebSocket transport 247 | 248 | Streamable HTTP is the modern MCP transport that replaces the older HTTP+SSE transport. According to the [official MCP documentation](https://modelcontextprotocol.io/docs/concepts/transports): "SSE as a standalone transport is deprecated as of protocol version 2025-03-26. It has been replaced by Streamable HTTP, which incorporates SSE as an optional streaming mechanism." 249 | 250 | ### Accessing Remote MCP Servers with OAuth Quickly 251 | 252 | If you need to use MCP servers that require OAuth, consider using **"[mcp-remote](https://www.npmjs.com/package/mcp-remote)"**. 253 | 254 | ```py 255 | "notionMCP": { 256 | "command": "npx", 257 | "args": ["-y", "mcp-remote", "https://mcp.notion.com/mcp"], 258 | }, 259 | ``` 260 | 261 | ### Authentication Support for Streamable HTTP Connections 262 | 263 | The library supports OAuth 2.1 authentication for Streamable HTTP connections: 264 | 265 | ```py 266 | from mcp.client.auth import OAuthClientProvider 267 | ... 268 | 269 | # Create OAuth authentication provider 270 | oauth_auth = OAuthClientProvider( 271 | server_url="https://...", 272 | client_metadata=..., 273 | storage=..., 274 | redirect_handler=..., 275 | callback_handler=..., 276 | ) 277 | 278 | # Test configuration with OAuth auth 279 | mcp_servers = { 280 | "secure-streamable-server": { 281 | "url": "https://.../mcp/", 282 | // To avoid auto protocol fallback, specify the protocol explicitly when using authentication 283 | "transport": "streamable_http", // or `"type": "http",` 284 | "auth": oauth_auth, 285 | "timeout": 30.0 286 | }, 287 | } 288 | ``` 289 | 290 | Test implementations are provided: 291 | 292 | - **Streamable HTTP Authentication Tests**: 293 | - MCP client uses this library: [streamable_http_oauth_test_client.py](https://github.com/hideya/langchain-mcp-tools-py/tree/main/testfiles/streamable_http_oauth_test_client.py) 294 | - Test MCP Server: [streamable_http_oauth_test_server.py](https://github.com/hideya/langchain-mcp-tools-py/tree/main/testfiles/streamable_http_oauth_test_server.py) 295 | 296 | ### Authentication Support for SSE Connections (Legacy) 297 | 298 | The library also supports authentication for SSE connections to MCP servers. 299 | Note that SSE transport is deprecated; Streamable HTTP is the recommended approach. 300 | 301 | ## API docs 302 | 303 | Can be found [here](https://hideya.github.io/langchain-mcp-tools-py/) 304 | 305 | ## Change Log 306 | 307 | Can be found [here](https://github.com/hideya/langchain-mcp-tools-py/blob/main/CHANGELOG.md) 308 | 309 | ## Building from Source 310 | 311 | See [README_DEV.md](https://github.com/hideya/langchain-mcp-tools-py/blob/main/README_DEV.md) for details. 312 | 313 | ## Appendix 314 | 315 | ### Troubleshooting 316 | 317 | 1. **Enable debug logging**: Set the log level to DEBUG to see detailed connection and execution logs: 318 | 319 | ``` 320 | tools, cleanup = await convert_mcp_to_langchain_tools( 321 | mcp_servers, 322 | logging.DEBUG 323 | ) 324 | ``` 325 | 2. **Check server errlog**: For stdio MCP servers, use `errlog` redirection to capture server error output 326 | 3. **Test explicit transports**: Try forcing specific transport types to isolate auto-detection issues 327 | 4. **Verify server independently**: Refer to [Debugging Section in MCP documentation](https://modelcontextprotocol.io/docs/tools/debugging) 328 | 329 | ### Troubleshooting Authentication Issues 330 | 331 | When authentication errors occur, they often generate massive logs that make it difficult to identify that authentication is the root cause. 332 | 333 | To address this problem, this library performs authentication pre-validation for HTTP/HTTPS MCP servers before attempting the actual MCP connection. 334 | This ensures that clear error messages like `Authentication failed (401 Unauthorized)` or `Authentication failed (403 Forbidden)` appear at the end of the logs, rather than being buried in the middle of extensive error output. 335 | 336 | **Important:** This pre-validation is specific to this library and not part of the official MCP specification. 337 | In rare cases, it may interfere with certain MCP server behaviors. 338 | 339 | #### When and How to Disable Pre-validation 340 | Set `"__pre_validate_authentication": False` in your server config if: 341 | - Using OAuth flows that require complex authentication handshakes 342 | - The MCP server doesn't accept simple HTTP POST requests for validation 343 | - You're experiencing false negatives in the auth validation 344 | 345 | **Example:** 346 | ```python 347 | "oauth-server": { 348 | "url": "https://api.example.com/mcp/", 349 | "auth": oauth_provider, # Complex OAuth provider 350 | "__pre_validate_authentication": False # Skip the pre-validation 351 | } 352 | ``` 353 | 354 | ### Debugging Authentication 355 | 1. **Check your tokens/credentials** - Most auth failures are due to expired or incorrect tokens 356 | 2. **Verify token permissions** - Some MCP servers require specific scopes (e.g., GitHub Copilot license) 357 | 3. **Test with curl** - Try a simple HTTP request to verify your auth setup: 358 | 359 | ```bash 360 | curl -H "Authorization: Bearer your-token" https://api.example.com/mcp/ 361 | ``` 362 | 363 | ### For Developers 364 | 365 | See [TECHNICAL.md](./TECHNICAL.md) for technical details about implementation challenges and solutions. 366 | -------------------------------------------------------------------------------- /src/langchain_mcp_tools/transport_utils.py: -------------------------------------------------------------------------------- 1 | """Utility functions for MCP server management and validation. 2 | 3 | This module contains helper functions used internally by the langchain_mcp_tools 4 | library for schema processing, error handling, authentication validation, 5 | transport testing, configuration validation, transport creation, and logging setup. 6 | """ 7 | 8 | import logging 9 | import os 10 | import time 11 | from contextlib import AsyncExitStack 12 | from typing import Any, TypeAlias, cast 13 | from urllib.parse import urlparse 14 | 15 | try: 16 | import httpx 17 | from anyio.streams.memory import ( 18 | MemoryObjectReceiveStream, 19 | MemoryObjectSendStream, 20 | ) 21 | from mcp.client.sse import sse_client 22 | from mcp.client.stdio import stdio_client, StdioServerParameters 23 | from mcp.client.streamable_http import streamablehttp_client 24 | from mcp.client.websocket import websocket_client 25 | import mcp.types as mcp_types 26 | except ImportError as e: 27 | print(f"\nError: Required package not found: {e}") 28 | print("Please ensure all required packages are installed\n") 29 | import sys 30 | sys.exit(1) 31 | 32 | 33 | class McpInitializationError(Exception): 34 | """Raised when MCP server initialization fails.""" 35 | 36 | def __init__(self, message: str, server_name: str | None = None): 37 | self.server_name = server_name 38 | super().__init__(message) 39 | 40 | def __str__(self) -> str: 41 | if self.server_name: 42 | return f'MCP server "{self.server_name}": {super().__str__()}' 43 | return super().__str__() 44 | 45 | 46 | # Type alias for bidirectional communication channels with MCP servers 47 | # Note: This type is not officially exported by mcp.types but represents 48 | # the standard transport interface used by all MCP client implementations 49 | Transport: TypeAlias = tuple[ 50 | MemoryObjectReceiveStream[mcp_types.JSONRPCMessage | Exception], 51 | MemoryObjectSendStream[mcp_types.JSONRPCMessage] 52 | ] 53 | 54 | 55 | def _is_4xx_error(error: Exception) -> bool: 56 | """Enhanced 4xx error detection for transport fallback decisions. 57 | 58 | Used to decide whether to fall back from Streamable HTTP to SSE transport 59 | per MCP specification. Handles various error types and patterns that 60 | indicate 4xx-like conditions. 61 | 62 | Args: 63 | error: The error to check 64 | 65 | Returns: 66 | True if the error represents a 4xx HTTP status or equivalent 67 | """ 68 | if not error: 69 | return False 70 | 71 | # Handle ExceptionGroup (Python 3.11+) by checking sub-exceptions 72 | if hasattr(error, 'exceptions'): 73 | return any(_is_4xx_error(sub_error) for sub_error in error.exceptions) 74 | 75 | # Check for explicit HTTP status codes 76 | if hasattr(error, 'status') and isinstance(error.status, int): 77 | return 400 <= error.status < 500 78 | 79 | # Check for httpx response errors 80 | if hasattr(error, 'response') and hasattr(error.response, 'status_code'): 81 | return 400 <= error.response.status_code < 500 82 | 83 | # Check error message for 4xx patterns 84 | error_str = str(error).lower() 85 | 86 | # Look for specific 4xx status codes (enhanced pattern matching) 87 | if any(code in error_str for code in ['400', '401', '402', '403', '404', '405', '406', '407', '408', '409']): 88 | return True 89 | 90 | # Look for 4xx error names (expanded list matching TypeScript version) 91 | return any(pattern in error_str for pattern in [ 92 | 'bad request', 93 | 'unauthorized', 94 | 'forbidden', 95 | 'not found', 96 | 'method not allowed', 97 | 'not acceptable', 98 | 'request timeout', 99 | 'conflict' 100 | ]) 101 | 102 | 103 | async def _validate_auth_before_connection( 104 | url_str: str, 105 | headers: dict[str, str] | None = None, 106 | timeout: float = 30.0, 107 | auth: httpx.Auth | None = None, 108 | logger: logging.Logger = logging.getLogger(__name__), 109 | server_name: str = "Unknown" 110 | ) -> tuple[bool, str]: 111 | """Pre-validate authentication with a simple HTTP request before creating MCP connection. 112 | 113 | This function helps avoid async generator cleanup bugs in the MCP client library 114 | by detecting authentication failures early, before the problematic MCP transport 115 | creation process begins. 116 | 117 | For OAuth authentication, this function skips validation since OAuth requires 118 | a complex flow that cannot be pre-validated with a simple HTTP request. 119 | Use __pre_validate_authentication=False to skip this validation. 120 | 121 | Args: 122 | url_str: The MCP server URL to test 123 | headers: Optional HTTP headers (typically containing Authorization) 124 | timeout: Request timeout in seconds 125 | auth: Optional httpx authentication object (OAuth providers are skipped) 126 | logger: Logger for debugging 127 | server_name: MCP server name to be validated 128 | 129 | Returns: 130 | Tuple of (success: bool, message: str) where: 131 | - success=True means authentication is valid or OAuth (skipped) 132 | - success=False means authentication failed with descriptive message 133 | 134 | Note: 135 | This function only validates simple authentication (401, 402, 403 errors). 136 | OAuth authentication is skipped since it requires complex flows. 137 | """ 138 | 139 | # Skip auth validation for httpx.Auth providers (OAuth, etc.) 140 | # These require complex authentication flows that cannot be pre-validated 141 | # with a simple HTTP request 142 | if auth is not None: 143 | auth_class_name = auth.__class__.__name__ 144 | logger.info(f'MCP server "{server_name}": Skipping auth validation for httpx.Auth provider: {auth_class_name}') 145 | return True, "httpx.Auth authentication skipped (requires full flow)" 146 | 147 | # Create InitializeRequest as per MCP specification (similar to test_streamable_http_support) 148 | init_request = { 149 | "jsonrpc": "2.0", 150 | "id": f"auth-test-{int(time.time() * 1000)}", 151 | "method": "initialize", 152 | "params": { 153 | "protocolVersion": "2024-11-05", 154 | "capabilities": {}, 155 | "clientInfo": { 156 | "name": "mcp-auth-test", 157 | "version": "1.0.0" 158 | } 159 | } 160 | } 161 | 162 | # Required headers per MCP specification 163 | request_headers = { 164 | 'Content-Type': 'application/json', 165 | 'Accept': 'application/json, text/event-stream' 166 | } 167 | if headers: 168 | request_headers.update(headers) 169 | 170 | try: 171 | async with httpx.AsyncClient() as client: 172 | logger.debug(f"Pre-validating authentication for: {url_str}") 173 | response = await client.post( 174 | url_str, 175 | json=init_request, 176 | headers=request_headers, 177 | timeout=timeout, 178 | auth=auth 179 | ) 180 | 181 | if response.status_code == 401: 182 | return False, f"Authentication failed (401 Unauthorized): {response.text if hasattr(response, 'text') else 'Unknown error'}" 183 | elif response.status_code == 402: 184 | return False, f"Authentication failed (402 Payment Required): {response.text if hasattr(response, 'text') else 'Unknown error'}" 185 | elif response.status_code == 403: 186 | return False, f"Authentication failed (403 Forbidden): {response.text if hasattr(response, 'text') else 'Unknown error'}" 187 | 188 | logger.info(f'MCP server "{server_name}": Authentication validation passed: {response.status_code}') 189 | return True, "Authentication validation passed" 190 | 191 | except httpx.HTTPStatusError as e: 192 | return False, f"HTTP Error ({e.response.status_code}): {e}" 193 | except (httpx.ConnectError, httpx.TimeoutException) as e: 194 | return False, f"Connection failed: {e}" 195 | except Exception as e: 196 | return False, f"Unexpected error during auth validation: {e}" 197 | 198 | 199 | async def _test_streamable_http_support( 200 | url: str, 201 | headers: dict[str, str] | None = None, 202 | timeout: float = 30.0, 203 | auth: httpx.Auth | None = None, 204 | logger: logging.Logger = logging.getLogger(__name__) 205 | ) -> bool: 206 | """Test if URL supports Streamable HTTP per official MCP specification. 207 | 208 | Follows the MCP specification's recommended approach for backwards compatibility. 209 | Uses proper InitializeRequest with official protocol version and required headers. 210 | 211 | See: https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#backwards-compatibility 212 | 213 | Args: 214 | url: The MCP server URL to test 215 | headers: Optional HTTP headers 216 | timeout: Request timeout 217 | auth: Optional httpx authentication 218 | logger: Logger for debugging 219 | 220 | Returns: 221 | True if Streamable HTTP is supported, False if should fallback to SSE 222 | 223 | Raises: 224 | Exception: For non-4xx errors that should be re-raised 225 | """ 226 | # Create InitializeRequest as per MCP specification 227 | init_request = { 228 | "jsonrpc": "2.0", 229 | "id": f"transport-test-{int(time.time() * 1000)}", # Use milliseconds like TS version 230 | "method": "initialize", 231 | "params": { 232 | "protocolVersion": "2024-11-05", # Official MCP Protocol version 233 | "capabilities": {}, 234 | "clientInfo": { 235 | "name": "mcp-transport-test", 236 | "version": "1.0.0" 237 | } 238 | } 239 | } 240 | 241 | # Required headers per MCP specification 242 | request_headers = { 243 | 'Content-Type': 'application/json', 244 | 'Accept': 'application/json, text/event-stream' # Required by spec 245 | } 246 | if headers: 247 | request_headers.update(headers) 248 | 249 | try: 250 | async with httpx.AsyncClient(follow_redirects=True) as client: 251 | logger.debug(f"Testing Streamable HTTP: POST InitializeRequest to {url}") 252 | response = await client.post( 253 | url, 254 | json=init_request, 255 | headers=request_headers, 256 | timeout=timeout, 257 | auth=auth 258 | ) 259 | 260 | logger.debug(f"Transport test response: {response.status_code} {response.headers.get('content-type', 'N/A')}") 261 | 262 | if response.status_code == 200: 263 | # Success indicates Streamable HTTP support 264 | logger.debug("Streamable HTTP test successful") 265 | return True 266 | elif 400 <= response.status_code < 500: 267 | # 4xx error indicates fallback to SSE per MCP spec 268 | logger.debug(f"Received {response.status_code}, should fallback to SSE") 269 | return False 270 | else: 271 | # Other errors should be re-raised 272 | response.raise_for_status() 273 | return True # If we get here, it succeeded 274 | 275 | except httpx.TimeoutException: 276 | logger.debug("Request timeout - treating as connection error") 277 | raise 278 | except httpx.ConnectError: 279 | logger.debug("Connection error") 280 | raise 281 | except Exception as e: 282 | # Check if it's a 4xx-like error using improved detection 283 | if _is_4xx_error(e): 284 | logger.debug(f"4xx-like error detected: {e}") 285 | return False 286 | raise 287 | 288 | 289 | def _validate_mcp_server_config( 290 | server_name: str, 291 | server_config: Any, # Use Any to avoid circular import, will be properly typed in main file 292 | logger: logging.Logger 293 | ) -> None: 294 | """Validates MCP server configuration following TypeScript transport selection logic. 295 | 296 | Transport Selection Priority: 297 | 1. Explicit transport/type field (must match URL protocol if URL provided) 298 | 2. URL protocol auto-detection (http/https → StreamableHTTP, ws/wss → WebSocket) 299 | 3. Command presence → Stdio transport 300 | 4. Error if none of the above match 301 | 302 | Conflicts that cause errors: 303 | - Both url and command specified 304 | - transport/type doesn't match URL protocol 305 | - transport requires URL but no URL provided 306 | - transport requires command but no command provided 307 | 308 | Args: 309 | server_name: Server instance name for error messages 310 | server_config: Configuration to validate 311 | logger: Logger for warnings 312 | 313 | Raises: 314 | McpInitializationError: If configuration is invalid 315 | """ 316 | has_url = "url" in server_config and server_config["url"] is not None 317 | has_command = "command" in server_config and server_config["command"] is not None 318 | 319 | # Get transport type (prefer 'transport' over 'type' for compatibility) 320 | transport_type = server_config.get("transport") or server_config.get("type") 321 | 322 | # Conflict check: Both url and command specified 323 | if has_url and has_command: 324 | raise McpInitializationError( 325 | f'Cannot specify both "url" ({server_config["url"]}) ' 326 | f'and "command" ({server_config["command"]}). Use "url" for remote servers ' 327 | f'or "command" for local servers.', 328 | server_name=server_name 329 | ) 330 | 331 | # Must have either URL or command 332 | if not has_url and not has_command: 333 | raise McpInitializationError( 334 | 'Either "url" or "command" must be specified', 335 | server_name=server_name 336 | ) 337 | 338 | if has_url: 339 | url_str = str(server_config["url"]) 340 | try: 341 | parsed_url = urlparse(url_str) 342 | url_scheme = parsed_url.scheme.lower() 343 | except Exception: 344 | raise McpInitializationError( 345 | f'Invalid URL format: {url_str}', 346 | server_name=server_name 347 | ) 348 | 349 | if transport_type: 350 | transport_lower = transport_type.lower() 351 | 352 | # Check transport/URL protocol compatibility 353 | if transport_lower in ["http", "streamable_http"] and url_scheme not in ["http", "https"]: 354 | raise McpInitializationError( 355 | f'Transport "{transport_type}" requires ' 356 | f'http:// or https:// URL, but got: {url_scheme}://', 357 | server_name=server_name 358 | ) 359 | elif transport_lower == "sse" and url_scheme not in ["http", "https"]: 360 | raise McpInitializationError( 361 | f'Transport "sse" requires ' 362 | f'http:// or https:// URL, but got: {url_scheme}://', 363 | server_name=server_name 364 | ) 365 | elif transport_lower in ["ws", "websocket"] and url_scheme not in ["ws", "wss"]: 366 | raise McpInitializationError( 367 | f'Transport "{transport_type}" requires ' 368 | f'ws:// or wss:// URL, but got: {url_scheme}://', 369 | server_name=server_name 370 | ) 371 | elif transport_lower == "stdio": 372 | raise McpInitializationError( 373 | f'Transport "stdio" requires "command", ' 374 | f'but "url" was provided', 375 | server_name=server_name 376 | ) 377 | 378 | # Validate URL scheme is supported 379 | if url_scheme not in ["http", "https", "ws", "wss"]: 380 | raise McpInitializationError( 381 | f'Unsupported URL scheme "{url_scheme}". ' 382 | f'Supported schemes: http, https, ws, wss', 383 | server_name=server_name 384 | ) 385 | 386 | elif has_command: 387 | if transport_type: 388 | transport_lower = transport_type.lower() 389 | 390 | # Check transport requires command 391 | if transport_lower == "stdio": 392 | pass # Valid 393 | elif transport_lower in ["http", "streamable_http", "sse", "ws", "websocket"]: 394 | raise McpInitializationError( 395 | f'Transport "{transport_type}" requires "url", ' 396 | f'but "command" was provided', 397 | server_name=server_name 398 | ) 399 | else: 400 | logger.warning( 401 | f'MCP server "{server_name}": Unknown transport type "{transport_type}", ' 402 | f'treating as stdio' 403 | ) 404 | -------------------------------------------------------------------------------- /src/langchain_mcp_tools/langchain_mcp_tools.py: -------------------------------------------------------------------------------- 1 | # Public API 2 | __all__ = [ 3 | 'convert_mcp_to_langchain_tools', 4 | 'McpServersConfig', 5 | 'SingleMcpServerConfig', 6 | 'McpServerCommandBasedConfig', 7 | 'McpServerUrlBasedConfig', 8 | 'McpInitializationError' 9 | ] 10 | 11 | # Standard library imports 12 | import logging 13 | import os 14 | import sys 15 | from contextlib import AsyncExitStack, asynccontextmanager 16 | from typing import ( 17 | Awaitable, 18 | Callable, 19 | cast, 20 | NotRequired, 21 | TextIO, 22 | TypeAlias, 23 | TypedDict, 24 | ) 25 | from urllib.parse import urlparse 26 | import time 27 | 28 | # Third-party imports 29 | try: 30 | from anyio.streams.memory import ( 31 | MemoryObjectReceiveStream, 32 | MemoryObjectSendStream, 33 | ) 34 | import httpx 35 | from langchain_core.tools import BaseTool 36 | from mcp import ClientSession 37 | from mcp.client.sse import sse_client 38 | from mcp.client.stdio import stdio_client, StdioServerParameters 39 | from mcp.client.streamable_http import streamablehttp_client 40 | from mcp.client.websocket import websocket_client 41 | from mcp.shared._httpx_utils import McpHttpClientFactory 42 | import mcp.types as mcp_types 43 | # from pydantic_core import to_json 44 | except ImportError as e: 45 | print(f"\nError: Required package not found: {e}") 46 | print("Please ensure all required packages are installed\n") 47 | sys.exit(1) 48 | 49 | # Local imports 50 | from .tool_adapter import create_mcp_langchain_adapter 51 | from .transport_utils import ( 52 | Transport, 53 | McpInitializationError, 54 | _validate_auth_before_connection, 55 | _test_streamable_http_support, 56 | _validate_mcp_server_config, 57 | ) 58 | 59 | 60 | class McpServerCommandBasedConfig(TypedDict): 61 | """Configuration for an MCP server launched via command line. 62 | 63 | This configuration is used for local MCP servers that are started as child 64 | processes using the stdio client. It defines the command to run, optional 65 | arguments, environment variables, working directory, and error logging 66 | options. 67 | 68 | Attributes: 69 | command: The executable command to run (e.g., "npx", "uvx", "python"). 70 | args: Optional list of command-line arguments to pass to the command. 71 | env: Optional dictionary of environment variables to set for the 72 | process. 73 | cwd: Optional working directory where the command will be executed. 74 | errlog: Optional file-like object for redirecting the server's stderr 75 | output. 76 | 77 | Example: 78 | { 79 | "command": "npx", 80 | "args": ["-y", "@modelcontextprotocol/server-filesystem", "."], 81 | "env": {"NODE_ENV": "production"}, 82 | "cwd": "/path/to/working/directory", 83 | "errlog": open("server.log", "w") 84 | } 85 | """ 86 | command: str 87 | args: NotRequired[list[str] | None] 88 | env: NotRequired[dict[str, str] | None] 89 | cwd: NotRequired[str | None] 90 | errlog: NotRequired[TextIO | None] 91 | 92 | 93 | class McpServerUrlBasedConfig(TypedDict): 94 | """Configuration for a remote MCP server accessed via URL. 95 | 96 | This configuration is used for remote MCP servers that are accessed via 97 | HTTP/HTTPS (Streamable HTTP, Server-Sent Events) or WebSocket connections. 98 | It defines the URL to connect to and optional HTTP headers for authentication. 99 | 100 | Note: Per MCP spec, clients should try Streamable HTTP first, then fallback 101 | to SSE on 4xx errors for maximum compatibility. 102 | 103 | Attributes: 104 | url: The URL of the remote MCP server. For HTTP/HTTPS servers, 105 | use http:// or https:// prefix. For WebSocket servers, 106 | use ws:// or wss:// prefix. 107 | transport: Optional transport type. Supported values: 108 | "streamable_http" or "http" (recommended, attempted first), 109 | "sse" (deprecated, fallback), "websocket" 110 | type: Optional alternative field name for transport (for compatibility) 111 | headers: Optional dictionary of HTTP headers to include in the request, 112 | typically used for authentication (e.g., bearer tokens). 113 | timeout: Optional timeout for HTTP requests (default: 30.0 seconds). 114 | sse_read_timeout: Optional timeout for SSE connections (SSE only). 115 | terminate_on_close: Optional flag to terminate on connection close. 116 | httpx_client_factory: Optional factory for creating HTTP clients. 117 | auth: Optional httpx authentication for requests. 118 | __pre_validate_authentication: Optional flag to skip auth validation 119 | (default: True). Set to False for OAuth flows that require 120 | complex authentication flows. 121 | 122 | Example for auto-detection (recommended): 123 | { 124 | "url": "https://api.example.com/mcp", 125 | # Auto-tries Streamable HTTP first, falls back to SSE on 4xx 126 | "headers": {"Authorization": "Bearer token123"}, 127 | "timeout": 60.0 128 | } 129 | 130 | Example for explicit Streamable HTTP: 131 | { 132 | "url": "https://api.example.com/mcp", 133 | "transport": "streamable_http", 134 | "headers": {"Authorization": "Bearer token123"}, 135 | "timeout": 60.0 136 | } 137 | 138 | Example for explicit SSE (legacy): 139 | { 140 | "url": "https://example.com/mcp/sse", 141 | "transport": "sse", 142 | "headers": {"Authorization": "Bearer token123"} 143 | } 144 | 145 | Example for WebSocket: 146 | { 147 | "url": "wss://example.com/mcp/ws", 148 | "transport": "websocket" 149 | } 150 | """ 151 | url: str 152 | transport: NotRequired[str] # Preferred field name 153 | type: NotRequired[str] # Alternative field name for compatibility 154 | headers: NotRequired[dict[str, str] | None] 155 | timeout: NotRequired[float] 156 | sse_read_timeout: NotRequired[float] 157 | terminate_on_close: NotRequired[bool] 158 | httpx_client_factory: NotRequired[McpHttpClientFactory] 159 | auth: NotRequired[httpx.Auth] 160 | __prevalidate_authentication: NotRequired[bool] 161 | 162 | # Type for a single MCP server configuration, which can be either 163 | # command-based or URL-based. 164 | SingleMcpServerConfig = McpServerCommandBasedConfig | McpServerUrlBasedConfig 165 | """Configuration for a single MCP server, either command-based or URL-based. 166 | 167 | This type represents the configuration for a single MCP server, which can 168 | be either: 169 | 1. A local server launched via command line (McpServerCommandBasedConfig) 170 | 2. A remote server accessed via URL (McpServerUrlBasedConfig) 171 | 172 | The type is determined by the presence of either the "command" key 173 | (for command-based) or the "url" key (for URL-based). 174 | """ 175 | 176 | # Configuration dictionary for multiple MCP servers 177 | McpServersConfig = dict[str, SingleMcpServerConfig] 178 | """Configuration dictionary for multiple MCP servers. 179 | 180 | A dictionary mapping server names (as strings) to their respective 181 | configurations. Each server name acts as a logical identifier used for logging 182 | and debugging. The configuration for each server can be either command-based 183 | or URL-based. 184 | 185 | Example: 186 | { 187 | "filesystem": { 188 | "command": "npx", 189 | "args": ["-y", "@modelcontextprotocol/server-filesystem", "."] 190 | }, 191 | "fetch": { 192 | "command": "uvx", 193 | "args": ["mcp-server-fetch"] 194 | }, 195 | "auto-detection-server": { 196 | "url": "https://api.example.com/mcp", 197 | # Will try Streamable HTTP first, fallback to SSE on 4xx 198 | "headers": {"Authorization": "Bearer token123"}, 199 | "timeout": 60.0 200 | }, 201 | "explicit-sse-server": { 202 | "url": "https://legacy.example.com/mcp/sse", 203 | "transport": "sse", 204 | "headers": {"Authorization": "Bearer token123"} 205 | } 206 | } 207 | """ 208 | 209 | 210 | # Type alias for bidirectional communication channels with MCP servers 211 | # Note: This type is not officially exported by mcp.types but represents 212 | # the standard transport interface used by all MCP client implementations 213 | Transport: TypeAlias = tuple[ 214 | MemoryObjectReceiveStream[mcp_types.JSONRPCMessage | Exception], 215 | MemoryObjectSendStream[mcp_types.JSONRPCMessage] 216 | ] 217 | 218 | 219 | async def _connect_to_mcp_server( 220 | server_name: str, 221 | server_config: SingleMcpServerConfig, 222 | exit_stack: AsyncExitStack, 223 | logger: logging.Logger = logging.getLogger(__name__) 224 | ) -> Transport: 225 | """Establishes a connection to an MCP server with robust error handling. 226 | 227 | Implements consistent transport selection logic and includes authentication 228 | pre-validation to prevent async generator cleanup bugs in the MCP client library. 229 | 230 | Transport Selection Priority: 231 | 1. Explicit transport/type field (must match URL protocol if URL provided) 232 | 2. URL protocol auto-detection (http/https → StreamableHTTP, ws/wss → WebSocket) 233 | 3. Command presence → Stdio transport 234 | 4. Error if none of the above match 235 | 236 | For HTTP URLs without explicit transport, follows MCP specification backwards 237 | compatibility: try Streamable HTTP first, fallback to SSE on 4xx errors. 238 | 239 | Authentication Pre-validation: 240 | For HTTP/HTTPS servers, authentication is pre-validated before attempting 241 | the actual MCP connection to avoid async generator cleanup issues that can 242 | occur in the underlying MCP client library when authentication fails. 243 | 244 | Supports multiple transport types: 245 | - stdio: For local command-based servers 246 | - streamable_http, http: For Streamable HTTP servers 247 | - sse: For Server-Sent Events HTTP servers (legacy) 248 | - websocket, ws: For WebSocket servers 249 | 250 | Args: 251 | server_name: Server instance name to use for better logging and error context 252 | server_config: Configuration dictionary for server setup 253 | exit_stack: AsyncExitStack for managing transport lifecycle and cleanup 254 | logger: Logger instance for debugging and monitoring 255 | 256 | Returns: 257 | A Transport tuple containing receive and send streams for server communication 258 | 259 | Raises: 260 | McpInitializationError: If configuration is invalid or server initialization fails 261 | Exception: If unexpected errors occur during connection 262 | """ 263 | try: 264 | logger.info(f'MCP server "{server_name}": ' 265 | f"initializing with: {server_config}") 266 | 267 | # Validate configuration first 268 | _validate_mcp_server_config(server_name, server_config, logger) 269 | 270 | # Determine if URL-based or command-based 271 | has_url = "url" in server_config and server_config["url"] is not None 272 | has_command = "command" in server_config and server_config["command"] is not None 273 | 274 | # Get transport type (prefer 'transport' over 'type') 275 | transport_type = server_config.get("transport") or server_config.get("type") 276 | 277 | if has_url: 278 | # URL-based configuration 279 | url_config = cast(McpServerUrlBasedConfig, server_config) 280 | url_str = str(url_config["url"]) 281 | parsed_url = urlparse(url_str) 282 | url_scheme = parsed_url.scheme.lower() 283 | 284 | # Extract common parameters 285 | headers = url_config.get("headers", None) 286 | timeout = url_config.get("timeout", None) 287 | auth = url_config.get("auth", None) 288 | 289 | if url_scheme in ["http", "https"]: 290 | # HTTP/HTTPS: Handle explicit transport or auto-detection 291 | if url_config.get("__pre_validate_authentication", True): 292 | # Pre-validate authentication to avoid MCP async generator cleanup bugs 293 | logger.info(f'MCP server "{server_name}": Pre-validating authentication') 294 | auth_valid, auth_message = await _validate_auth_before_connection( 295 | url_str, 296 | headers=headers, 297 | timeout=timeout or 30.0, 298 | auth=auth, 299 | logger=logger, 300 | server_name=server_name 301 | ) 302 | 303 | if not auth_valid: 304 | # logger.error(f'MCP server "{server_name}": {auth_message}') 305 | raise McpInitializationError(auth_message, server_name=server_name) 306 | 307 | # Now proceed with the original connection logic 308 | if transport_type and transport_type.lower() in ["streamable_http", "http"]: 309 | # Explicit Streamable HTTP (no fallback) 310 | logger.info(f'MCP server "{server_name}": ' 311 | f"connecting via Streamable HTTP (explicit) to {url_str}") 312 | 313 | kwargs = {} 314 | if headers is not None: 315 | kwargs["headers"] = headers 316 | if timeout is not None: 317 | kwargs["timeout"] = timeout 318 | if auth is not None: 319 | kwargs["auth"] = auth 320 | 321 | transport = await exit_stack.enter_async_context( 322 | streamablehttp_client(url_str, **kwargs) 323 | ) 324 | 325 | elif transport_type and transport_type.lower() == "sse": 326 | # Explicit SSE (no fallback) 327 | logger.info(f'MCP server "{server_name}": ' 328 | f"connecting via SSE (explicit) to {url_str}") 329 | logger.warning(f'MCP server "{server_name}": ' 330 | f"Using SSE transport (deprecated as of MCP 2025-03-26), consider migrating to streamable_http") 331 | 332 | transport = await exit_stack.enter_async_context( 333 | sse_client(url_str, headers=headers) 334 | ) 335 | 336 | else: 337 | # Auto-detection: URL protocol suggests HTTP transport, try Streamable HTTP first 338 | logger.debug(f'MCP server "{server_name}": ' 339 | f"auto-detecting HTTP transport using MCP specification method") 340 | 341 | try: 342 | logger.info(f'MCP server "{server_name}": ' 343 | f"testing Streamable HTTP support for {url_str}") 344 | 345 | supports_streamable = await _test_streamable_http_support( 346 | url_str, 347 | headers=headers, 348 | timeout=timeout, 349 | auth=auth, 350 | logger=logger 351 | ) 352 | 353 | if supports_streamable: 354 | logger.info(f'MCP server "{server_name}": ' 355 | f"detected Streamable HTTP transport support") 356 | 357 | kwargs = {} 358 | if headers is not None: 359 | kwargs["headers"] = headers 360 | if timeout is not None: 361 | kwargs["timeout"] = timeout 362 | if auth is not None: 363 | kwargs["auth"] = auth 364 | 365 | transport = await exit_stack.enter_async_context( 366 | streamablehttp_client(url_str, **kwargs) 367 | ) 368 | 369 | else: 370 | logger.info(f'MCP server "{server_name}": ' 371 | f"received 4xx error, falling back to SSE transport") 372 | logger.warning(f'MCP server "{server_name}": ' 373 | f"Using SSE transport (deprecated as of MCP 2025-03-26), server should support Streamable HTTP") 374 | 375 | transport = await exit_stack.enter_async_context( 376 | sse_client(url_str, headers=headers) 377 | ) 378 | 379 | except Exception as error: 380 | logger.error(f'MCP server "{server_name}": ' 381 | f"transport detection failed: {error}") 382 | raise 383 | 384 | elif url_scheme in ["ws", "wss"]: 385 | # WebSocket transport 386 | if transport_type and transport_type.lower() not in ["websocket", "ws"]: 387 | logger.warning(f'MCP server "{server_name}": ' 388 | f'URL scheme "{url_scheme}" suggests WebSocket, ' 389 | f'but transport "{transport_type}" specified') 390 | 391 | logger.info(f'MCP server "{server_name}": ' 392 | f"connecting via WebSocket to {url_str}") 393 | 394 | transport = await exit_stack.enter_async_context( 395 | websocket_client(url_str) 396 | ) 397 | 398 | else: 399 | # This should be caught by validation, but include for safety 400 | raise McpInitializationError( 401 | f'Unsupported URL scheme "{url_scheme}". ' 402 | f'Supported schemes: http/https (for streamable_http/sse), ws/wss (for websocket)', 403 | server_name=server_name 404 | ) 405 | 406 | elif has_command: 407 | # Command-based configuration (stdio transport) 408 | if transport_type and transport_type.lower() not in ["stdio", ""]: 409 | logger.warning(f'MCP server "{server_name}": ' 410 | f'Command provided suggests stdio transport, ' 411 | f'but transport "{transport_type}" specified') 412 | 413 | logger.info(f'MCP server "{server_name}": ' 414 | f"spawning local process via stdio") 415 | 416 | # NOTE: `uv` and `npx` seem to require PATH to be set. 417 | # To avoid confusion, it was decided to automatically append it 418 | # to the env if not explicitly set by the config. 419 | config = cast(McpServerCommandBasedConfig, server_config) 420 | # env = config.get("env", {}) doesn't work since it can yield None 421 | env_val = config.get("env") 422 | env = {} if env_val is None else dict(env_val) 423 | if "PATH" not in env: 424 | env["PATH"] = os.environ.get("PATH", "") 425 | 426 | # Use stdio client for commands 427 | # args = config.get("args", []) doesn't work since it can yield None 428 | args_val = config.get("args") 429 | args = [] if args_val is None else list(args_val) 430 | server_parameters = StdioServerParameters( 431 | command=config.get("command", ""), 432 | args=args, 433 | env=env, 434 | cwd=config.get("cwd", None) 435 | ) 436 | 437 | # Initialize stdio client and register it with exit stack for cleanup 438 | errlog_val = config.get("errlog") 439 | kwargs = {"errlog": errlog_val} if errlog_val is not None else {} 440 | transport = await exit_stack.enter_async_context( 441 | stdio_client(server_parameters, **kwargs) 442 | ) 443 | 444 | else: 445 | # This should be caught by validation, but include for safety 446 | raise McpInitializationError( 447 | 'Invalid configuration - ' 448 | 'either "url" or "command" must be specified', 449 | server_name=server_name 450 | ) 451 | 452 | except Exception as e: 453 | logger.error(f'MCP server "{server_name}": error during initialization: {str(e)}') 454 | raise 455 | 456 | return transport 457 | 458 | 459 | async def _get_mcp_server_tools( 460 | server_name: str, 461 | transport: Transport, 462 | exit_stack: AsyncExitStack, 463 | logger: logging.Logger = logging.getLogger(__name__) 464 | ) -> list[BaseTool]: 465 | """Retrieves and converts MCP server tools to LangChain BaseTool format. 466 | 467 | Establishes a client session with the MCP server, lists available tools, 468 | and wraps each tool in a LangChain-compatible adapter class. The adapter 469 | handles async execution, error handling, and result formatting. 470 | 471 | Tool Conversion Features: 472 | - JSON Schema to Pydantic model conversion for argument validation 473 | - Async-only execution (raises NotImplementedError for sync calls) 474 | - Automatic result formatting from MCP TextContent to strings 475 | - Error handling with ToolException for MCP tool failures 476 | - Comprehensive logging of tool input/output and execution metrics 477 | 478 | Args: 479 | server_name: Server instance name for logging and error context 480 | transport: Communication channels tuple (2-tuple for SSE/stdio, 3-tuple for streamable HTTP) 481 | exit_stack: AsyncExitStack for managing session lifecycle and cleanup 482 | logger: Logger instance for debugging and monitoring 483 | 484 | Returns: 485 | List of LangChain BaseTool instances that wrap MCP server tools 486 | 487 | Raises: 488 | McpInitializationError: If transport format is unexpected or session initialization fails 489 | Exception: If tool retrieval or conversion fails 490 | """ 491 | try: 492 | # Handle both 2-tuple (SSE, stdio) and 3-tuple (streamable HTTP) returns 493 | # Third element in streamable HTTP contains session info/metadata 494 | if len(transport) == 2: 495 | read, write = transport 496 | elif len(transport) == 3: 497 | read, write, _ = transport # Third element is session info/metadata 498 | else: 499 | raise McpInitializationError( 500 | f"Unexpected transport tuple length: {len(transport)}", 501 | server_name=server_name 502 | ) 503 | 504 | # Use an intermediate `asynccontextmanager` to log the cleanup message 505 | @asynccontextmanager 506 | async def log_before_aexit(context_manager, message): 507 | """Helper context manager that logs before cleanup""" 508 | yield await context_manager.__aenter__() 509 | try: 510 | logger.info(message) 511 | finally: 512 | await context_manager.__aexit__(None, None, None) 513 | 514 | # Initialize client session with cleanup logging 515 | session = await exit_stack.enter_async_context( 516 | log_before_aexit( 517 | ClientSession(read, write), 518 | f'MCP server "{server_name}": session closed' 519 | ) 520 | ) 521 | 522 | await session.initialize() 523 | logger.info(f'MCP server "{server_name}": connected') 524 | 525 | # Get MCP tools 526 | tools_response = await session.list_tools() 527 | 528 | # Wrap MCP tools into LangChain tools 529 | langchain_tools: list[BaseTool] = [] 530 | for tool in tools_response.tools: 531 | adapter = create_mcp_langchain_adapter(tool, session, server_name, logger) 532 | langchain_tools.append(adapter) 533 | 534 | # Log available tools for debugging 535 | logger.info(f'MCP server "{server_name}": {len(langchain_tools)} ' 536 | f"tool(s) available:") 537 | for tool in langchain_tools: 538 | logger.info(f"- {tool.name}") 539 | except Exception as e: 540 | logger.error(f'Error getting MCP tools: "{server_name}/{tool.name}": {str(e)}') 541 | raise 542 | 543 | return langchain_tools 544 | 545 | 546 | # Type hint for cleanup function 547 | McpServerCleanupFn = Callable[[], Awaitable[None]] 548 | """Type for the async cleanup function returned by convert_mcp_to_langchain_tools. 549 | 550 | This function encapsulates the cleanup of all MCP server connections managed by 551 | the AsyncExitStack. When called, it properly closes all transport connections, 552 | sessions, and resources in the correct order. 553 | 554 | Important: Always call this function when you're done using the tools to prevent 555 | resource leaks and ensure graceful shutdown of MCP server connections. 556 | 557 | Example usage: 558 | tools, cleanup = await convert_mcp_to_langchain_tools(server_configs) 559 | try: 560 | # Use tools with your LangChain application... 561 | result = await tools[0].arun(param="value") 562 | finally: 563 | # Always cleanup, even if exceptions occur 564 | await cleanup() 565 | """ 566 | 567 | 568 | class ColorFormatter(logging.Formatter): 569 | COLORS = { 570 | 'DEBUG': '\x1b[90m', # Gray 571 | 'INFO': '\x1b[90m', # Gray 572 | 'WARNING': '\x1b[93m', # Yellow 573 | 'ERROR': '\x1b[91m', # Red 574 | 'CRITICAL': '\x1b[1;101m' # Red background, Bold text 575 | } 576 | RESET = '\x1b[0m' 577 | 578 | def format(self, record): 579 | levelname_color = self.COLORS.get(record.levelname, '') 580 | record.levelname = f"{levelname_color}[{record.levelname}]{self.RESET}" 581 | return super().format(record) 582 | 583 | 584 | def _init_logger(log_level=logging.INFO) -> logging.Logger: 585 | """Creates a simple pre-configured logger. 586 | 587 | Returns: 588 | A configured Logger instance 589 | """ 590 | handler = logging.StreamHandler() 591 | handler.setFormatter(ColorFormatter("%(levelname)s %(message)s")) 592 | 593 | logger = logging.getLogger() 594 | logger.setLevel(log_level) 595 | logger.handlers = [] # Clear existing handlers 596 | logger.addHandler(handler) 597 | 598 | return logger 599 | 600 | 601 | async def convert_mcp_to_langchain_tools( 602 | server_configs: McpServersConfig, 603 | logger: logging.Logger | int | None = None 604 | ) -> tuple[list[BaseTool], McpServerCleanupFn]: 605 | """Initialize multiple MCP servers and convert their tools to LangChain format. 606 | 607 | This is the main entry point for the library. It orchestrates the complete 608 | lifecycle of multiple MCP server connections, from initialization through 609 | tool conversion to cleanup. Provides robust error handling and authentication 610 | pre-validation to prevent common MCP client library issues. 611 | 612 | Key Features: 613 | - Parallel initialization of multiple servers for efficiency 614 | - Authentication pre-validation for HTTP servers to prevent async generator bugs 615 | - Automatic transport selection and fallback per MCP specification 616 | - Comprehensive error handling with McpInitializationError 617 | - User-controlled cleanup via returned async function 618 | - Support for both local (stdio) and remote (HTTP/WebSocket) servers 619 | 620 | Transport Support: 621 | - stdio: Local command-based servers (npx, uvx, python, etc.) 622 | - streamable_http: Modern HTTP servers (recommended, tried first) 623 | - sse: Legacy Server-Sent Events HTTP servers (fallback) 624 | - websocket: WebSocket servers for real-time communication 625 | 626 | Error Handling: 627 | All configuration and connection errors are wrapped in McpInitializationError 628 | with server context for easy debugging. Authentication failures are detected 629 | early to prevent async generator cleanup issues in the MCP client library. 630 | 631 | Args: 632 | server_configs: Dictionary mapping server names to configurations. 633 | Each config can be either McpServerCommandBasedConfig for local 634 | servers or McpServerUrlBasedConfig for remote servers. 635 | logger: Optional logger instance. If None, creates a pre-configured 636 | logger with appropriate levels for MCP debugging. 637 | If a logging level (e.g., `logging.DEBUG`), the pre-configured 638 | logger will be initialized with that level. 639 | 640 | Returns: 641 | A tuple containing: 642 | - List[BaseTool]: All tools from all servers, ready for LangChain use 643 | - McpServerCleanupFn: Async function to properly shutdown all connections 644 | 645 | Raises: 646 | McpInitializationError: If any server fails to initialize with detailed context 647 | 648 | Example: 649 | server_configs = { 650 | "local-filesystem": { 651 | "command": "npx", 652 | "args": ["-y", "@modelcontextprotocol/server-filesystem", "."] 653 | }, 654 | "remote-api": { 655 | "url": "https://api.example.com/mcp", 656 | "headers": {"Authorization": "Bearer your-token"}, 657 | "timeout": 30.0 658 | } 659 | } 660 | 661 | try: 662 | tools, cleanup = await convert_mcp_to_langchain_tools(server_configs) 663 | 664 | # Use tools with your LangChain application 665 | for tool in tools: 666 | result = await tool.arun(**tool_args) 667 | 668 | except McpInitializationError as e: 669 | print(f"Failed to initialize MCP server '{e.server_name}': {e}") 670 | 671 | finally: 672 | # Always cleanup when done 673 | await cleanup() 674 | """ 675 | 676 | if logger is None: 677 | logger = logging.getLogger(__name__) 678 | # Check if the root logger has handlers configured 679 | if not logging.root.handlers and not logger.handlers: 680 | # No logging configured, use a simple pre-configured logger 681 | logger = _init_logger() 682 | elif isinstance(logger, int): 683 | # logger is actually a level like logging.DEBUG 684 | logger = _init_logger(log_level=logger) 685 | elif isinstance(logger, logging.Logger): 686 | # already a logger, use as is 687 | pass 688 | else: 689 | raise TypeError( 690 | "logger must be a logging.Logger, int (log level), or None" 691 | ) 692 | 693 | # Initialize AsyncExitStack for managing multiple server lifecycles 694 | transports: list[Transport] = [] 695 | async_exit_stack = AsyncExitStack() 696 | 697 | # Initialize all MCP servers concurrently 698 | for server_name, server_config in server_configs.items(): 699 | # NOTE for stdio MCP servers: 700 | # the following `await` only blocks until the server subprocess 701 | # is spawned, i.e. after returning from the `await`, the spawned 702 | # subprocess starts its initialization independently of (so in 703 | # parallel with) the Python execution of the following lines. 704 | transport = await _connect_to_mcp_server( 705 | server_name, 706 | server_config, 707 | async_exit_stack, 708 | logger 709 | ) 710 | transports.append(transport) 711 | 712 | # Convert tools from each server to LangChain format 713 | langchain_tools: list[BaseTool] = [] 714 | for (server_name, server_config), transport in zip( 715 | server_configs.items(), 716 | transports, 717 | strict=True 718 | ): 719 | tools = await _get_mcp_server_tools( 720 | server_name, 721 | transport, 722 | async_exit_stack, 723 | logger 724 | ) 725 | langchain_tools.extend(tools) 726 | 727 | # Define a cleanup function to properly shut down all servers 728 | async def mcp_cleanup() -> None: 729 | """Closes all server connections and cleans up resources.""" 730 | await async_exit_stack.aclose() 731 | 732 | # Log summary of initialized tools 733 | logger.info(f"MCP servers initialized: {len(langchain_tools)} tool(s) " 734 | f"available in total") 735 | for tool in langchain_tools: 736 | logger.debug(f"- {tool.name}") 737 | 738 | return langchain_tools, mcp_cleanup 739 | --------------------------------------------------------------------------------