# Directory Structure
```
├── .github
│ └── workflows
│ ├── canary.yml
│ ├── publish.yml
│ └── test.yml
├── .gitignore
├── .python-version
├── .vscode
│ └── settings.json
├── LICENSE
├── pyproject.toml
├── README.md
├── src
│ └── langchain_mcp
│ ├── __init__.py
│ ├── py.typed
│ └── toolkit.py
├── tests
│ ├── conftest.py
│ ├── demo.py
│ ├── integration_tests
│ │ ├── __init__.py
│ │ └── test_tool.py
│ └── unit_tests
│ ├── __init__.py
│ └── test_tool.py
└── uv.lock
```
# Files
--------------------------------------------------------------------------------
/.python-version:
--------------------------------------------------------------------------------
```
1 | 3.10
2 |
```
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
```
1 | # Python-generated files
2 | __pycache__/
3 | *.py[oc]
4 | build/
5 | dist/
6 | wheels/
7 | *.egg-info
8 |
9 | # Virtual environments
10 | .venv
11 |
```
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
```markdown
1 | # langchain-mcp
2 |
3 | *Note: langchain now has a more official implementation [langchain-mcp-adapters](https://github.com/langchain-ai/langchain-mcp-adapters)*
4 |
5 | 
6 |
7 | [Model Context Protocol](https://modelcontextprotocol.io) tool calling support in LangChain.
8 |
9 | Create a `langchain_mcp.MCPToolkit` with an `mcp.ClientSession`,
10 | then `await toolkit.initialize()` and `toolkit.get_tools()` to get the list of `langchain_core.tools.BaseTool`s.
11 |
12 | Example:
13 |
14 | https://github.com/rectalogic/langchain-mcp/blob/8fa8445a24755bf91789f52718c32361ed916f46/tests/demo.py#L34-L43
15 |
16 | ## Demo
17 |
18 | You can run the demo against [Groq](https://groq.com/) `llama-3.1-8b-instant`:
19 | ```sh-session
20 | $ export GROQ_API_KEY=xxx
21 | $ uv run tests/demo.py "Read and summarize the file ./LICENSE"
22 | Secure MCP Filesystem Server running on stdio
23 | Allowed directories: [ '/users/aw/projects/rectalogic/langchain-mcp' ]
24 | The file ./LICENSE is a MIT License agreement. It states that the software is provided "as is" without warranty and that the authors and copyright holders are not liable for any claims, damages, or other liability arising from the software or its use.
25 | ```
26 |
```
--------------------------------------------------------------------------------
/tests/integration_tests/__init__.py:
--------------------------------------------------------------------------------
```python
1 |
```
--------------------------------------------------------------------------------
/tests/unit_tests/__init__.py:
--------------------------------------------------------------------------------
```python
1 |
```
--------------------------------------------------------------------------------
/src/langchain_mcp/__init__.py:
--------------------------------------------------------------------------------
```python
1 | # Copyright (C) 2024 Andrew Wason
2 | # SPDX-License-Identifier: MIT
3 |
4 | from .toolkit import MCPToolkit # noqa: F401
5 |
```
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "[python]": {
3 | "editor.formatOnSave": true,
4 | "editor.codeActionsOnSave": {
5 | "source.organizeImports": "explicit"
6 | },
7 | "editor.defaultFormatter": "charliermarsh.ruff"
8 | }
9 | }
10 |
```
--------------------------------------------------------------------------------
/tests/unit_tests/test_tool.py:
--------------------------------------------------------------------------------
```python
1 | # Copyright (C) 2024 Andrew Wason
2 | # SPDX-License-Identifier: MIT
3 |
4 | import pytest
5 | from langchain_tests.unit_tests import ToolsUnitTests
6 |
7 |
8 | @pytest.mark.usefixtures("mcptool")
9 | class TestMCPToolUnit(ToolsUnitTests):
10 | @property
11 | def tool_constructor(self):
12 | return self.tool
13 |
14 | @property
15 | def tool_invoke_params_example(self) -> dict:
16 | return {"path": "LICENSE"}
17 |
```
--------------------------------------------------------------------------------
/tests/integration_tests/test_tool.py:
--------------------------------------------------------------------------------
```python
1 | # Copyright (C) 2024 Andrew Wason
2 | # SPDX-License-Identifier: MIT
3 |
4 | import pytest
5 | from langchain_tests.integration_tests import ToolsIntegrationTests
6 |
7 |
8 | @pytest.mark.usefixtures("mcptool")
9 | class TestMCPToolIntegration(ToolsIntegrationTests):
10 | @property
11 | def tool_constructor(self):
12 | return self.tool
13 |
14 | @property
15 | def tool_invoke_params_example(self) -> dict:
16 | return {"path": "LICENSE"}
17 |
```
--------------------------------------------------------------------------------
/.github/workflows/canary.yml:
--------------------------------------------------------------------------------
```yaml
1 | name: Canary tests
2 |
3 | on:
4 | workflow_call:
5 | schedule:
6 | - cron: "* * * * 0"
7 |
8 | permissions:
9 | contents: read
10 |
11 | jobs:
12 | test:
13 | runs-on: ubuntu-latest
14 | strategy:
15 | matrix:
16 | python-version: ["3.10", "3.11", "3.12", "3.13"]
17 | steps:
18 | - uses: actions/checkout@v4
19 | - name: Install uv
20 | uses: astral-sh/setup-uv@v2
21 | with:
22 | enable-cache: false
23 | - name: Set up Python ${{ matrix.python-version }}
24 | uses: actions/setup-python@v5
25 | with:
26 | python-version: ${{ matrix.python-version }}
27 | - name: Run tests with upgraded packages
28 | run: uv run --upgrade --python ${{ matrix.python-version }} --python-preference only-system pytest
29 | - name: Mypy
30 | run: |
31 | uv run --upgrade --python ${{ matrix.python-version }} --python-preference only-system mypy
32 |
```
--------------------------------------------------------------------------------
/.github/workflows/test.yml:
--------------------------------------------------------------------------------
```yaml
1 | name: Test
2 |
3 | on: [push, pull_request, workflow_call]
4 |
5 | permissions:
6 | contents: read
7 |
8 | jobs:
9 | test:
10 | runs-on: ubuntu-latest
11 | strategy:
12 | fail-fast: false
13 | matrix:
14 | python-version: ["3.10", "3.11", "3.12", "3.13"]
15 | steps:
16 | - uses: actions/checkout@v4
17 | - name: Install uv
18 | uses: astral-sh/setup-uv@v2
19 | with:
20 | enable-cache: true
21 | cache-dependency-glob: "uv.lock"
22 | - name: Set up Python ${{ matrix.python-version }}
23 | uses: actions/setup-python@v5
24 | with:
25 | python-version: ${{ matrix.python-version }}
26 | - name: Run tests
27 | run: uv run --isolated --locked --python ${{ matrix.python-version }} --python-preference only-system pytest
28 | - name: Lint
29 | run: |
30 | uv run --python ${{ matrix.python-version }} --python-preference only-system ruff format --check
31 | uv run --python ${{ matrix.python-version }} --python-preference only-system ruff check
32 | - name: Mypy
33 | run: |
34 | uv run --python ${{ matrix.python-version }} --python-preference only-system mypy
35 |
```
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
```toml
1 | [project]
2 | name = "langchain-mcp"
3 | version = "0.2.1"
4 | description = "Model Context Protocol tool calling support for LangChain"
5 | readme = "README.md"
6 | authors = [{ name = "Andrew Wason", email = "[email protected]" }]
7 | requires-python = ">=3.10"
8 | dependencies = [
9 | "langchain-core~=0.3.37",
10 | "mcp~=1.0",
11 | "pydantic~=2.10",
12 | "typing-extensions~=4.12",
13 | ]
14 | classifiers = ["License :: OSI Approved :: MIT License"]
15 |
16 | [build-system]
17 | requires = ["hatchling"]
18 | build-backend = "hatchling.build"
19 |
20 | [dependency-groups]
21 | dev = [
22 | "langchain-tests~=0.3",
23 | "pytest~=8.3",
24 | "pytest-asyncio~=0.24",
25 | "pytest-socket~=0.7",
26 | "ruff~=0.8",
27 | "mypy~=1.13",
28 | "langchain-groq~=0.2",
29 | ]
30 |
31 | [project.urls]
32 | Repository = "https://github.com/rectalogic/langchain-mcp"
33 | Issues = "https://github.com/rectalogic/langchain-mcp/issues"
34 | Related = "https://modelcontextprotocol.io/"
35 |
36 | [tool.ruff]
37 | target-version = "py310"
38 | line-length = 120
39 |
40 | [tool.ruff.format]
41 | docstring-code-format = true
42 |
43 | [tool.ruff.lint]
44 | select = [
45 | # flake8-2020
46 | "YTT",
47 | # flake8-bandit
48 | "S",
49 | # flake8-bugbear
50 | "B",
51 | # flake8-builtins
52 | "A",
53 | # Pyflakes
54 | "F",
55 | # Pycodestyle
56 | "E",
57 | "W",
58 | # isort
59 | "I",
60 | # flake8-no-pep420
61 | "INP",
62 | # pyupgrade
63 | "UP",
64 | ]
65 |
66 | [tool.ruff.lint.per-file-ignores]
67 | "tests/*" = ["S", "INP001"]
68 |
69 | [tool.pytest.ini_options]
70 | asyncio_mode = "auto"
71 | asyncio_default_fixture_loop_scope = "class"
72 |
73 | [tool.mypy]
74 | disallow_untyped_defs = true
75 | warn_unused_configs = true
76 | warn_redundant_casts = true
77 | warn_unused_ignores = true
78 | strict_equality = true
79 | no_implicit_optional = true
80 | show_error_codes = true
81 | files = ["src/**/*.py", "tests/demo.py"]
82 | plugins = "pydantic.mypy"
83 |
```
--------------------------------------------------------------------------------
/tests/demo.py:
--------------------------------------------------------------------------------
```python
1 | # Copyright (C) 2024 Andrew Wason
2 | # SPDX-License-Identifier: MIT
3 |
4 | import asyncio
5 | import pathlib
6 | import sys
7 | import typing as t
8 |
9 | from langchain_core.messages import AIMessage, BaseMessage, HumanMessage
10 | from langchain_core.output_parsers import StrOutputParser
11 | from langchain_core.tools import BaseTool
12 | from langchain_groq import ChatGroq
13 | from mcp import ClientSession, StdioServerParameters
14 | from mcp.client.stdio import stdio_client
15 |
16 | from langchain_mcp import MCPToolkit
17 |
18 |
19 | async def run(tools: list[BaseTool], prompt: str) -> str:
20 | model = ChatGroq(model_name="llama-3.1-8b-instant", stop_sequences=None) # requires GROQ_API_KEY
21 | tools_map = {tool.name: tool for tool in tools}
22 | tools_model = model.bind_tools(tools)
23 | messages: list[BaseMessage] = [HumanMessage(prompt)]
24 | ai_message = t.cast(AIMessage, await tools_model.ainvoke(messages))
25 | messages.append(ai_message)
26 | for tool_call in ai_message.tool_calls:
27 | selected_tool = tools_map[tool_call["name"].lower()]
28 | tool_msg = await selected_tool.ainvoke(tool_call)
29 | messages.append(tool_msg)
30 | return await (tools_model | StrOutputParser()).ainvoke(messages)
31 |
32 |
33 | async def main(prompt: str) -> None:
34 | server_params = StdioServerParameters(
35 | command="npx",
36 | args=["-y", "@modelcontextprotocol/server-filesystem", str(pathlib.Path(__file__).parent.parent)],
37 | )
38 | async with stdio_client(server_params) as (read, write):
39 | async with ClientSession(read, write) as session:
40 | toolkit = MCPToolkit(session=session)
41 | await toolkit.initialize()
42 | response = await run(toolkit.get_tools(), prompt)
43 | print(response)
44 |
45 |
46 | if __name__ == "__main__":
47 | prompt = sys.argv[1] if len(sys.argv) > 1 else "Read and summarize the file ./LICENSE"
48 | asyncio.run(main(prompt))
49 |
```
--------------------------------------------------------------------------------
/tests/conftest.py:
--------------------------------------------------------------------------------
```python
1 | # Copyright (C) 2024 Andrew Wason
2 | # SPDX-License-Identifier: MIT
3 |
4 | from unittest import mock
5 |
6 | import pytest
7 | from langchain_tests.integration_tests import ToolsIntegrationTests
8 | from mcp import ClientSession, ListToolsResult, Tool
9 | from mcp.types import CallToolResult, TextContent
10 |
11 | from langchain_mcp import MCPToolkit
12 |
13 |
14 | @pytest.fixture(scope="class")
15 | def mcptoolkit(request):
16 | session_mock = mock.AsyncMock(spec=ClientSession)
17 | session_mock.list_tools.return_value = ListToolsResult(
18 | tools=[
19 | Tool(
20 | name="read_file",
21 | description=(
22 | "Read the complete contents of a file from the file system. Handles various text encodings "
23 | "and provides detailed error messages if the file cannot be read. "
24 | "Use this tool when you need to examine the contents of a single file. "
25 | "Only works within allowed directories."
26 | ),
27 | inputSchema={
28 | "type": "object",
29 | "properties": {"path": {"type": "string"}},
30 | "required": ["path"],
31 | "additionalProperties": False,
32 | "$schema": "http://json-schema.org/draft-07/schema#",
33 | },
34 | )
35 | ]
36 | )
37 | session_mock.call_tool.return_value = CallToolResult(
38 | content=[TextContent(type="text", text="MIT License\n\nCopyright (c) 2024 Andrew Wason\n")],
39 | isError=False,
40 | )
41 | toolkit = MCPToolkit(session=session_mock)
42 | yield toolkit
43 | if issubclass(request.cls, ToolsIntegrationTests):
44 | session_mock.call_tool.assert_called_with("read_file", arguments={"path": "LICENSE"})
45 |
46 |
47 | @pytest.fixture(scope="class")
48 | async def mcptool(request, mcptoolkit):
49 | await mcptoolkit.initialize()
50 | tool = mcptoolkit.get_tools()[0]
51 | request.cls.tool = tool
52 | yield tool
53 |
```
--------------------------------------------------------------------------------
/.github/workflows/publish.yml:
--------------------------------------------------------------------------------
```yaml
1 | name: Publish Python Package and GitHub Release
2 |
3 | on:
4 | push:
5 | tags:
6 | - '**'
7 |
8 | jobs:
9 | call-test:
10 | uses: ./.github/workflows/test.yml
11 |
12 | build:
13 | name: Build distribution package
14 | runs-on: ubuntu-latest
15 | needs: [call-test]
16 | steps:
17 | - uses: actions/checkout@v4
18 | - name: Install uv
19 | uses: astral-sh/setup-uv@v2
20 | with:
21 | enable-cache: true
22 | cache-dependency-glob: "uv.lock"
23 | - name: Set up Python 3.12
24 | uses: actions/setup-python@v5
25 | with:
26 | python-version: "3.12"
27 | - name: Install dependencies
28 | run: |
29 | uv sync --locked --dev --python 3.12 --python-preference only-system
30 | version=$(uv run --locked python -c 'from importlib.metadata import version; print(version("langchain_mcp"))')
31 | if [ "$version" != "${{ github.ref_name }}" ]; then
32 | echo "Built version $version does not match tag ${{ github.ref_name }}, aborting"
33 | exit 1
34 | fi
35 | - name: Build
36 | run: |
37 | uv build
38 | - name: Store the distribution packages
39 | uses: actions/upload-artifact@v4
40 | with:
41 | name: python-package-distribution
42 | path: dist/
43 |
44 | publish:
45 | name: Publish Python distribution package to PyPI
46 | runs-on: ubuntu-latest
47 | needs:
48 | - build
49 | environment: release
50 | permissions:
51 | id-token: write
52 | steps:
53 | - name: Download dist
54 | uses: actions/download-artifact@v4
55 | with:
56 | name: python-package-distribution
57 | path: dist/
58 | - name: Publish to PyPI
59 | uses: pypa/gh-action-pypi-publish@release/v1
60 |
61 | github-release:
62 | name: Sign the Python distribution package with Sigstore and upload to GitHub Release
63 | needs:
64 | - publish
65 | runs-on: ubuntu-latest
66 | permissions:
67 | contents: write
68 | id-token: write
69 | steps:
70 | - name: Download dists
71 | uses: actions/download-artifact@v4
72 | with:
73 | name: python-package-distribution
74 | path: dist/
75 | - name: Sign with Sigstore
76 | uses: sigstore/[email protected]
77 | with:
78 | inputs: >-
79 | ./dist/*.tar.gz
80 | ./dist/*.whl
81 | - name: Create GitHub Release
82 | env:
83 | GITHUB_TOKEN: ${{ github.token }}
84 | run: >-
85 | gh release create
86 | '${{ github.ref_name }}'
87 | --repo '${{ github.repository }}'
88 | --notes ""
89 | - name: Upload artifact signatures to GitHub Release
90 | env:
91 | GITHUB_TOKEN: ${{ github.token }}
92 | run: >-
93 | gh release upload
94 | '${{ github.ref_name }}' dist/**
95 | --repo '${{ github.repository }}'
```
--------------------------------------------------------------------------------
/src/langchain_mcp/toolkit.py:
--------------------------------------------------------------------------------
```python
1 | # Copyright (C) 2024 Andrew Wason
2 | # SPDX-License-Identifier: MIT
3 |
4 | import asyncio
5 | import warnings
6 | from collections.abc import Callable
7 |
8 | import pydantic
9 | import pydantic_core
10 | import typing_extensions as t
11 | from langchain_core.tools.base import BaseTool, BaseToolkit, ToolException
12 | from mcp import ClientSession, ListToolsResult
13 | from mcp.types import EmbeddedResource, ImageContent, TextContent
14 |
15 |
16 | class MCPToolkit(BaseToolkit):
17 | """
18 | MCP server toolkit
19 | """
20 |
21 | session: ClientSession
22 | """The MCP session used to obtain the tools"""
23 |
24 | _tools: ListToolsResult | None = None
25 |
26 | model_config = pydantic.ConfigDict(arbitrary_types_allowed=True)
27 |
28 | async def initialize(self) -> None:
29 | """Initialize the session and retrieve tools list"""
30 | if self._tools is None:
31 | await self.session.initialize()
32 | self._tools = await self.session.list_tools()
33 |
34 | @t.override
35 | def get_tools(self) -> list[BaseTool]:
36 | if self._tools is None:
37 | raise RuntimeError("Must initialize the toolkit first")
38 |
39 | return [
40 | MCPTool(
41 | session=self.session,
42 | name=tool.name,
43 | description=tool.description or "",
44 | args_schema=tool.inputSchema,
45 | )
46 | # list_tools returns a PaginatedResult, but I don't see a way to pass the cursor to retrieve more tools
47 | for tool in self._tools.tools
48 | ]
49 |
50 |
51 | class MCPTool(BaseTool):
52 | """
53 | MCP server tool
54 | """
55 |
56 | session: ClientSession
57 | handle_tool_error: bool | str | Callable[[ToolException], str] | None = True
58 | response_format: t.Literal["content", "content_and_artifact"] = "content_and_artifact"
59 |
60 | @t.override
61 | def _run(self, *args: t.Any, **kwargs: t.Any) -> tuple[str, list[ImageContent | EmbeddedResource]]:
62 | warnings.warn(
63 | "Invoke this tool asynchronousely using `ainvoke`. This method exists only to satisfy standard tests.",
64 | stacklevel=1,
65 | )
66 | return asyncio.run(self._arun(*args, **kwargs))
67 |
68 | @t.override
69 | async def _arun(self, *args: t.Any, **kwargs: t.Any) -> tuple[str, list[ImageContent | EmbeddedResource]]:
70 | result = await self.session.call_tool(self.name, arguments=kwargs)
71 | if result.isError:
72 | raise ToolException(pydantic_core.to_json(result.content).decode())
73 | text_content = [block for block in result.content if isinstance(block, TextContent)]
74 | artifacts = [block for block in result.content if not isinstance(block, TextContent)]
75 | return pydantic_core.to_json(text_content).decode(), artifacts
76 |
```