├── 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 [](https://github.com/hideya/langchain-mcp-tools-py/blob/main/LICENSE) [](https://pypi.org/project/langchain-mcp-tools/) [](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 |
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 |
--------------------------------------------------------------------------------