mcp-haystack 0.0.2__tar.gz → 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (24) hide show
  1. mcp_haystack-0.2.0/CHANGELOG.md +26 -0
  2. {mcp_haystack-0.0.2 → mcp_haystack-0.2.0}/PKG-INFO +4 -2
  3. mcp_haystack-0.2.0/examples/google_maps_agent.py +93 -0
  4. mcp_haystack-0.2.0/examples/mcp_filtered_tools.py +54 -0
  5. mcp_haystack-0.2.0/examples/mcp_sse_toolset.py +67 -0
  6. mcp_haystack-0.2.0/examples/mcp_stdio_toolset.py +60 -0
  7. mcp_haystack-0.2.0/examples/time_pipeline_toolset.py +61 -0
  8. {mcp_haystack-0.0.2 → mcp_haystack-0.2.0}/pydoc/config.yml +2 -1
  9. {mcp_haystack-0.0.2 → mcp_haystack-0.2.0}/pyproject.toml +15 -3
  10. {mcp_haystack-0.0.2 → mcp_haystack-0.2.0}/src/haystack_integrations/tools/mcp/__init__.py +6 -0
  11. {mcp_haystack-0.0.2 → mcp_haystack-0.2.0}/src/haystack_integrations/tools/mcp/mcp_tool.py +47 -16
  12. mcp_haystack-0.2.0/src/haystack_integrations/tools/mcp/mcp_toolset.py +257 -0
  13. {mcp_haystack-0.0.2 → mcp_haystack-0.2.0}/tests/test_mcp_tool.py +23 -3
  14. mcp_haystack-0.2.0/tests/test_mcp_toolset.py +296 -0
  15. mcp_haystack-0.0.2/CHANGELOG.md +0 -9
  16. mcp_haystack-0.0.2/examples/hayhooks/pipeline_wrapper.py +0 -82
  17. {mcp_haystack-0.0.2 → mcp_haystack-0.2.0}/.gitignore +0 -0
  18. {mcp_haystack-0.0.2 → mcp_haystack-0.2.0}/LICENSE.txt +0 -0
  19. {mcp_haystack-0.0.2 → mcp_haystack-0.2.0}/README.md +0 -0
  20. {mcp_haystack-0.0.2 → mcp_haystack-0.2.0}/examples/mcp_sse_client.py +0 -0
  21. {mcp_haystack-0.0.2 → mcp_haystack-0.2.0}/examples/mcp_sse_server.py +0 -0
  22. {mcp_haystack-0.0.2 → mcp_haystack-0.2.0}/examples/mcp_stdio_client.py +0 -0
  23. {mcp_haystack-0.0.2 → mcp_haystack-0.2.0}/examples/time_pipeline.py +0 -0
  24. {mcp_haystack-0.0.2 → mcp_haystack-0.2.0}/tests/__init__.py +0 -0
@@ -0,0 +1,26 @@
1
+ # Changelog
2
+
3
+ ## [integrations/mcp-v0.1.0] - 2025-04-22
4
+
5
+ ### 🚀 Features
6
+
7
+ - Add MCPToolset (#1626)
8
+
9
+
10
+ ## [integrations/mcp-v0.0.2] - 2025-04-11
11
+
12
+ ### 📚 Documentation
13
+
14
+ - Fix MCPTool import in code example (#1578)
15
+
16
+ ### 🌀 Miscellaneous
17
+
18
+ - Feat: Add AsyncExecutor in MCPTool, ensure MCPTool works in hayhooks (#1643)
19
+
20
+ ## [integrations/mcp-v0.0.1] - 2025-03-12
21
+
22
+ ### 🚀 Features
23
+
24
+ - Add MCP tooling integration (#1487)
25
+
26
+ <!-- generated by git-cliff -->
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mcp-haystack
3
- Version: 0.0.2
3
+ Version: 0.2.0
4
4
  Summary: Haystack integration for Model Context Protocol (MCP)
5
5
  Project-URL: Documentation, https://github.com/deepset-ai/haystack-core-integrations/tree/main/integrations/mcp#readme
6
6
  Project-URL: Issues, https://github.com/deepset-ai/haystack-core-integrations/issues
@@ -18,7 +18,9 @@ Classifier: Programming Language :: Python :: 3.12
18
18
  Classifier: Programming Language :: Python :: 3.13
19
19
  Classifier: Programming Language :: Python :: Implementation :: CPython
20
20
  Requires-Python: >=3.10
21
- Requires-Dist: haystack-ai>=2.9.0
21
+ Requires-Dist: exceptiongroup
22
+ Requires-Dist: haystack-ai>=2.13.0
23
+ Requires-Dist: httpx
22
24
  Requires-Dist: mcp
23
25
  Description-Content-Type: text/markdown
24
26
 
@@ -0,0 +1,93 @@
1
+ # SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ ### Overview
6
+ # This script shows how to use the **Google Maps MCP Server** in combination with the **Haystack Agent** and **OpenAI's Chat Generator** to search for places via the Google Places API.
7
+
8
+ # ---
9
+
10
+ # ### 🔧 Step 1: Start the Google Maps MCP Server
11
+
12
+ # Make sure you have a valid **Google Maps API key**.
13
+ # See [Google Maps API Key](https://developers.google.com/maps/documentation/places/web-service/get-api-key) for more information.
14
+
15
+ # Run the following Docker command to start the MCP Server locally on port 8000:
16
+
17
+ # ```bash
18
+ # docker run -it --rm -p 8000:8000 \
19
+ # -e GOOGLE_MAPS_API_KEY=$GOOGLE_MAPS_API_KEY \
20
+ # supercorp/supergateway \
21
+ # --stdio "npx -y @modelcontextprotocol/server-google-maps" \
22
+ # --port 8000
23
+ # ```
24
+
25
+ # ---
26
+
27
+ # ### 🕵️ Step 2: Inspect Available Tools (Optional but useful)
28
+
29
+ # You can verify that the MCP server is running and see the available tools using:
30
+
31
+ # ```bash
32
+ # npx -y @modelcontextprotocol/inspector
33
+ # ```
34
+
35
+ # Connect MCP Inspector to the server at `http://localhost:8000/sse` and click on "List Tools" to display tools such as:
36
+
37
+ # - `maps_geocode`: Convert address → coordinates
38
+ # - `maps_reverse_geocode`: Convert coordinates → address
39
+ # - `maps_search_places`: Search places (e.g., restaurants)
40
+ # - `maps_place_details`: Get details of a specific place
41
+ # - `maps_distance_matrix`: Calculate travel distance/time
42
+ # - `maps_elevation`: Get elevation info
43
+ # - `maps_directions`: Get route directions
44
+
45
+ # ---
46
+
47
+ # ### ▶️ Step 3: Run the Python Script
48
+
49
+ # This script sets up:
50
+ # - A **Langfuse trace**
51
+ # - A **MCPToolset** with selected tools (`maps_geocode`, `maps_search_places`)
52
+ # - An **Agent** using `gpt-4.1` to process a natural language request
53
+
54
+ # To run the script:
55
+
56
+ # ```bash
57
+ # python examples/google_maps_agent.py
58
+ # ```
59
+
60
+ # The agent will respond to this example query:
61
+
62
+ # > "Find the five best Persian restaurants close to Zinnowitzer Str. 1, 10115 Berlin, Germany"
63
+
64
+ from haystack.components.agents import Agent
65
+ from haystack.components.generators.chat import OpenAIChatGenerator
66
+ from haystack.dataclasses import ChatMessage
67
+
68
+ # from haystack_integrations.components.connectors.langfuse.langfuse_connector import LangfuseConnector
69
+ from haystack_integrations.tools.mcp.mcp_tool import SSEServerInfo
70
+ from haystack_integrations.tools.mcp.mcp_toolset import MCPToolset
71
+
72
+
73
+ def main():
74
+ # tracer = LangfuseConnector("Agent google maps search")
75
+ # Optionally, you can use Langfuse to trace the agent's activity but it needs
76
+ # additional configuration.
77
+ # See [Langfuse integration](https://github.com/deepset-ai/haystack-core-integrations/tree/main/integrations/langfuse) for more information.
78
+ toolset = MCPToolset(
79
+ SSEServerInfo(base_url="http://localhost:8000"), tool_names=["maps_geocode", "maps_search_places"]
80
+ )
81
+ agent = Agent(chat_generator=OpenAIChatGenerator(model="gpt-4.1"), tools=toolset)
82
+ result = agent.run(
83
+ messages=[
84
+ ChatMessage.from_user(
85
+ text="Find the five best persian restaurants close to Zinnowitzer Str. 1, 10115 Berlin, Germany"
86
+ )
87
+ ]
88
+ )
89
+ print(result["messages"][-1].text)
90
+
91
+
92
+ if __name__ == "__main__":
93
+ main()
@@ -0,0 +1,54 @@
1
+ # SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ from haystack_integrations.tools.mcp import MCPToolset, SSEServerInfo
6
+
7
+ # This example demonstrates using MCPToolset with SSE transport
8
+ # and filtering tools by name
9
+ # Run this client after running the server mcp_sse_server.py
10
+ # It shows how MCPToolset can selectively include only specific tools
11
+
12
+
13
+ def main():
14
+ """Example of using MCPToolset with filtered tools."""
15
+
16
+ try:
17
+ print("Creating toolset with all available tools:")
18
+ # Create a toolset with all available tools
19
+ full_toolset = MCPToolset(
20
+ server_info=SSEServerInfo(base_url="http://localhost:8000"),
21
+ )
22
+
23
+ # Print all discovered tools
24
+ print(f"Discovered {len(full_toolset)} tools:")
25
+ for tool in full_toolset:
26
+ print(f" - {tool.name}: {tool.description}")
27
+
28
+ print("\nCreating toolset with filtered tools:")
29
+ # Create a toolset with only specific tools
30
+ # In this example, we're only including the 'add' tool
31
+ filtered_toolset = MCPToolset(
32
+ server_info=SSEServerInfo(base_url="http://localhost:8000"),
33
+ tool_names=["add"], # Only include the 'add' tool
34
+ )
35
+
36
+ # Print filtered tools
37
+ print(f"Filtered toolset has {len(filtered_toolset)} tools:")
38
+ for tool in filtered_toolset:
39
+ print(f" - {tool.name}: {tool.description}")
40
+
41
+ # Use the filtered toolset
42
+ if len(filtered_toolset) > 0:
43
+ add_tool = filtered_toolset.tools[0] # The only tool should be 'add'
44
+ result = add_tool.invoke(a=10, b=5)
45
+ print(f"\nInvoking {add_tool.name}: 10 + 5 = {result.content[0].text}")
46
+ else:
47
+ print("No tools available in the filtered toolset")
48
+
49
+ except Exception as e:
50
+ print(f"Error in filtered toolset example: {e}")
51
+
52
+
53
+ if __name__ == "__main__":
54
+ main()
@@ -0,0 +1,67 @@
1
+ # SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ from haystack_integrations.tools.mcp import MCPToolset, SSEServerInfo
6
+
7
+ # This example demonstrates using MCPToolset with SSE transport
8
+ # Run this client after running the server mcp_sse_server.py
9
+ # It shows how MCPToolset automatically discovers and creates tools from the MCP server
10
+
11
+
12
+ def find_tool(toolset, tool_name):
13
+ """
14
+ Find a tool by name in the toolset.
15
+
16
+ :param toolset: The toolset to search in
17
+ :param tool_name: The name of the tool to find
18
+ :returns: The tool if found, None otherwise
19
+ """
20
+ for tool in toolset:
21
+ if tool.name == tool_name:
22
+ return tool
23
+ return None
24
+
25
+
26
+ def main():
27
+ """Example of using MCPToolset with SSE transport."""
28
+
29
+ try:
30
+ # Create the toolset - this automatically discovers all available tools
31
+ # from the MCP server and creates Tool instances for each one
32
+ sse_toolset = MCPToolset(
33
+ server_info=SSEServerInfo(base_url="http://localhost:8000"),
34
+ )
35
+
36
+ # Print discovered tools
37
+ print(f"Discovered {len(sse_toolset)} tools:")
38
+ for tool in sse_toolset:
39
+ print(f" - {tool.name}: {tool.description}")
40
+
41
+ # Get tools by name from the toolset
42
+ add_tool = find_tool(sse_toolset, "add")
43
+ if not add_tool:
44
+ print("Add tool not found!")
45
+ return
46
+
47
+ subtract_tool = find_tool(sse_toolset, "subtract")
48
+ if not subtract_tool:
49
+ print("Subtract tool not found!")
50
+ return
51
+
52
+ # Use the tools
53
+ result = add_tool.invoke(a=7, b=3)
54
+ print(f"7 + 3 = {result.content[0].text}")
55
+
56
+ result = subtract_tool.invoke(a=5, b=3)
57
+ print(f"5 - 3 = {result.content[0].text}")
58
+
59
+ result = add_tool.invoke(a=10, b=20)
60
+ print(f"10 + 20 = {result.content[0].text}")
61
+
62
+ except Exception as e:
63
+ print(f"Error in SSE toolset example: {e}")
64
+
65
+
66
+ if __name__ == "__main__":
67
+ main()
@@ -0,0 +1,60 @@
1
+ # SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ from haystack_integrations.tools.mcp import MCPToolset, StdioServerInfo
6
+
7
+ # This example shows how to use MCPToolset with stdio transport
8
+ # MCPToolset automatically discovers all available tools from the MCP server
9
+ # Here we use the mcp-server-time server
10
+ # See https://github.com/modelcontextprotocol/servers/tree/main/src/time for more details
11
+ # prior to running this script, pip install mcp-server-time
12
+
13
+
14
+ def find_tool(toolset, tool_name):
15
+ """
16
+ Find a tool by name in the toolset.
17
+
18
+ :param toolset: The toolset to search in
19
+ :param tool_name: The name of the tool to find
20
+ :returns: The tool if found, None otherwise
21
+ """
22
+ for tool in toolset:
23
+ if tool.name == tool_name:
24
+ return tool
25
+ return None
26
+
27
+
28
+ def main():
29
+ """Example of using the MCPToolset implementation with stdio transport."""
30
+
31
+ try:
32
+ # Create server info for the time service
33
+ server_info = StdioServerInfo(command="uvx", args=["mcp-server-time", "--local-timezone=Europe/Berlin"])
34
+
35
+ # Create the toolset - this will automatically discover all available tools
36
+ stdio_toolset = MCPToolset(server_info=server_info)
37
+
38
+ # Print discovered tools
39
+ print(f"Discovered {len(stdio_toolset)} tools:")
40
+ for tool in stdio_toolset:
41
+ print(f" - {tool.name}: {tool.description}")
42
+
43
+ # Find tools by name using the helper function
44
+ time_tool = find_tool(stdio_toolset, "get_current_time")
45
+ if not time_tool:
46
+ print("Time tool not found!")
47
+ return
48
+
49
+ # Use the get_current_time tool
50
+ result = time_tool.invoke(timezone="America/New_York")
51
+ print(f"Current time in New York: {result.content[0].text}")
52
+
53
+ result = time_tool.invoke(timezone="America/Los_Angeles")
54
+ print(f"Current time in Los Angeles: {result.content[0].text}")
55
+ except Exception as e:
56
+ print(f"Error in stdio toolset example: {e}")
57
+
58
+
59
+ if __name__ == "__main__":
60
+ main()
@@ -0,0 +1,61 @@
1
+ # SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ # Full example of a pipeline that uses MCPToolset to get the current time
6
+ # and then uses the time to answer a user question.
7
+ # Here we use the mcp-server-time mcp package
8
+ # See https://github.com/modelcontextprotocol/servers/tree/main/src/time for more details
9
+ # prior to running this script, pip install mcp-server-time
10
+
11
+ import os
12
+
13
+ from haystack import Pipeline
14
+ from haystack.components.converters import OutputAdapter
15
+ from haystack.components.generators.chat import OpenAIChatGenerator
16
+ from haystack.components.tools import ToolInvoker
17
+ from haystack.dataclasses import ChatMessage
18
+
19
+ from haystack_integrations.tools.mcp import MCPToolset, StdioServerInfo
20
+
21
+
22
+ def main():
23
+ # Create server info for the time service
24
+ server_info = StdioServerInfo(command="uvx", args=["mcp-server-time", "--local-timezone=Europe/Berlin"])
25
+
26
+ # Create the toolset - this will automatically discover all available tools
27
+ mcp_toolset = MCPToolset(server_info)
28
+ # Check if OpenAI API key is set
29
+ api_key = os.environ.get("OPENAI_API_KEY")
30
+ if not api_key:
31
+ print("OPENAI_API_KEY environment variable is not set.")
32
+ print("You need to set it to run the pipeline example.")
33
+ print("For now, demonstrating direct tool usage:")
34
+
35
+ pipeline = Pipeline()
36
+ pipeline.add_component("llm", OpenAIChatGenerator(model="gpt-4o-mini", tools=mcp_toolset))
37
+ pipeline.add_component("tool_invoker", ToolInvoker(tools=mcp_toolset))
38
+ pipeline.add_component(
39
+ "adapter",
40
+ OutputAdapter(
41
+ template="{{ initial_msg + initial_tool_messages + tool_messages }}",
42
+ output_type=list[ChatMessage],
43
+ unsafe=True,
44
+ ),
45
+ )
46
+ pipeline.add_component("response_llm", OpenAIChatGenerator(model="gpt-4o-mini"))
47
+ pipeline.connect("llm.replies", "tool_invoker.messages")
48
+ pipeline.connect("llm.replies", "adapter.initial_tool_messages")
49
+ pipeline.connect("tool_invoker.tool_messages", "adapter.tool_messages")
50
+ pipeline.connect("adapter.output", "response_llm.messages")
51
+
52
+ user_input = "What is the time in New York? Be brief." # can be any city
53
+ user_input_msg = ChatMessage.from_user(text=user_input)
54
+
55
+ result = pipeline.run({"llm": {"messages": [user_input_msg]}, "adapter": {"initial_msg": [user_input_msg]}})
56
+
57
+ print(result["response_llm"]["replies"][0].text)
58
+
59
+
60
+ if __name__ == "__main__":
61
+ main()
@@ -3,6 +3,7 @@ loaders:
3
3
  search_path: [../src]
4
4
  modules: [
5
5
  "haystack_integrations.tools.mcp.mcp_tool",
6
+ "haystack_integrations.tools.mcp.mcp_toolset",
6
7
  ]
7
8
  ignore_when_discovered: ["__init__"]
8
9
  processors:
@@ -17,7 +18,7 @@ renderer:
17
18
  type: haystack_pydoc_tools.renderers.ReadmeIntegrationRenderer
18
19
  excerpt: MCP integration for Haystack
19
20
  category_slug: integrations-api
20
- title: mcp
21
+ title: MCP
21
22
  slug: integrations-mcp
22
23
  order: 145
23
24
  markdown:
@@ -27,7 +27,12 @@ classifiers = [
27
27
  "Programming Language :: Python :: 3.13",
28
28
  "Programming Language :: Python :: Implementation :: CPython",
29
29
  ]
30
- dependencies = ["mcp", "haystack-ai>=2.9.0"]
30
+ dependencies = [
31
+ "mcp",
32
+ "haystack-ai>=2.13.0",
33
+ "exceptiongroup", # Backport of ExceptionGroup for Python < 3.11
34
+ "httpx" # HTTP client library used for SSE connections
35
+ ]
31
36
 
32
37
  [project.urls]
33
38
  Documentation = "https://github.com/deepset-ai/haystack-core-integrations/tree/main/integrations/mcp#readme"
@@ -75,7 +80,7 @@ dependencies = [
75
80
  "pip",
76
81
  "black>=23.1.0",
77
82
  "mypy>=1.0.0",
78
- "ruff>=0.0.243",
83
+ "ruff>=0.0.243"
79
84
  ]
80
85
 
81
86
  [tool.hatch.envs.lint.scripts]
@@ -133,6 +138,10 @@ ignore = [
133
138
  "PLR0912",
134
139
  "PLR0913",
135
140
  "PLR0915",
141
+ # Ignore the errors for process open we're seeing in tests
142
+ "RUF005",
143
+ "S603",
144
+ "S607",
136
145
  ]
137
146
  unfixable = [
138
147
  # Don't touch unused imports
@@ -165,9 +174,12 @@ module = [
165
174
  "haystack.*",
166
175
  "haystack_integrations.*",
167
176
  "pytest.*",
177
+ "pytest_asyncio",
168
178
  "anyio.*",
169
179
  "mcp.*",
170
180
  "mcp",
181
+ "httpx",
182
+ "exceptiongroup"
171
183
  ]
172
184
  ignore_missing_imports = true
173
185
 
@@ -181,4 +193,4 @@ log_cli = true
181
193
  asyncio_default_fixture_loop_scope = "function"
182
194
 
183
195
  [tool.hatch.metadata]
184
- allow-direct-references = true
196
+ allow-direct-references = true
@@ -1,3 +1,7 @@
1
+ # SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
1
5
  from .mcp_tool import (
2
6
  MCPClient,
3
7
  MCPConnectionError,
@@ -11,6 +15,7 @@ from .mcp_tool import (
11
15
  StdioClient,
12
16
  StdioServerInfo,
13
17
  )
18
+ from .mcp_toolset import MCPToolset
14
19
 
15
20
  __all__ = [
16
21
  "MCPClient",
@@ -20,6 +25,7 @@ __all__ = [
20
25
  "MCPServerInfo",
21
26
  "MCPTool",
22
27
  "MCPToolNotFoundError",
28
+ "MCPToolset",
23
29
  "SSEClient",
24
30
  "SSEServerInfo",
25
31
  "StdioClient",
@@ -5,6 +5,7 @@
5
5
  import asyncio
6
6
  import concurrent.futures
7
7
  import threading
8
+ import warnings
8
9
  from abc import ABC, abstractmethod
9
10
  from collections.abc import Coroutine
10
11
  from contextlib import AsyncExitStack
@@ -16,6 +17,7 @@ from haystack import logging
16
17
  from haystack.core.serialization import generate_qualified_class_name, import_class_by_name
17
18
  from haystack.tools import Tool
18
19
  from haystack.tools.errors import ToolInvocationError
20
+ from haystack.utils.url_validation import is_valid_http_url
19
21
 
20
22
  from mcp import ClientSession, StdioServerParameters, types
21
23
  from mcp.client.sse import sse_client
@@ -351,18 +353,19 @@ class SSEClient(MCPClient):
351
353
  MCP client that connects to servers using SSE transport.
352
354
  """
353
355
 
354
- def __init__(self, base_url: str, token: str | None = None, timeout: int = 5) -> None:
356
+ def __init__(self, server_info: "SSEServerInfo") -> None:
355
357
  """
356
- Initialize an SSE MCP client.
358
+ Initialize an SSE MCP client using server configuration.
357
359
 
358
- :param base_url: Base URL of the server
359
- :param token: Authentication token for the server (optional)
360
- :param timeout: Connection timeout in seconds
360
+ :param server_info: Configuration object containing URL, token, timeout, etc.
361
361
  """
362
362
  super().__init__()
363
- self.base_url: str = base_url.rstrip("/") # Remove any trailing slashes
364
- self.token: str | None = token
365
- self.timeout: int = timeout
363
+
364
+ # in post_init we validate the url and set the url field so it is guaranteed to be valid
365
+ # safely ignore the mypy warning here
366
+ self.url: str = server_info.url # type: ignore[assignment]
367
+ self.token: str | None = server_info.token
368
+ self.timeout: int = server_info.timeout
366
369
 
367
370
  async def connect(self) -> list[Tool]:
368
371
  """
@@ -371,12 +374,11 @@ class SSEClient(MCPClient):
371
374
  :returns: List of available tools on the server
372
375
  :raises MCPConnectionError: If connection to the server fails
373
376
  """
374
- sse_url = f"{self.base_url}/sse"
375
377
  headers = {"Authorization": f"Bearer {self.token}"} if self.token else None
376
378
  sse_transport = await self.exit_stack.enter_async_context(
377
- sse_client(sse_url, headers=headers, timeout=self.timeout)
379
+ sse_client(self.url, headers=headers, timeout=self.timeout)
378
380
  )
379
- return await self._initialize_session_with_transport(sse_transport, f"HTTP server at {self.base_url}")
381
+ return await self._initialize_session_with_transport(sse_transport, f"HTTP server at {self.url}")
380
382
 
381
383
 
382
384
  @dataclass
@@ -432,22 +434,51 @@ class SSEServerInfo(MCPServerInfo):
432
434
  """
433
435
  Data class that encapsulates SSE MCP server connection parameters.
434
436
 
435
- :param base_url: Base URL of the MCP server
437
+ :param url: Full URL of the MCP server (including /sse endpoint)
438
+ :param base_url: Base URL of the MCP server (deprecated, use url instead)
436
439
  :param token: Authentication token for the server (optional)
437
440
  :param timeout: Connection timeout in seconds
438
441
  """
439
442
 
440
- base_url: str
443
+ url: str | None = None
444
+ base_url: str | None = None # deprecated
441
445
  token: str | None = None
442
446
  timeout: int = 30
443
447
 
448
+ def __post_init__(self):
449
+ """Validate that either url or base_url is provided."""
450
+ if not self.url and not self.base_url:
451
+ message = "Either url or base_url must be provided"
452
+ raise ValueError(message)
453
+ if self.url and self.base_url:
454
+ message = "Only one of url or base_url should be provided, if both are provided, base_url will be ignored"
455
+ warnings.warn(message, DeprecationWarning, stacklevel=2)
456
+
457
+ if self.base_url:
458
+ if not is_valid_http_url(self.base_url):
459
+ message = f"Invalid base_url: {self.base_url}"
460
+ raise ValueError(message)
461
+
462
+ warnings.warn(
463
+ "base_url is deprecated and will be removed in a future version. Use url instead.",
464
+ DeprecationWarning,
465
+ stacklevel=2,
466
+ )
467
+ # from now on only use url for the lifetime of the SSEServerInfo instance, never base_url
468
+ self.url = f"{self.base_url.rstrip('/')}/sse"
469
+
470
+ elif not is_valid_http_url(self.url):
471
+ message = f"Invalid url: {self.url}"
472
+ raise ValueError(message)
473
+
444
474
  def create_client(self) -> MCPClient:
445
475
  """
446
476
  Create an SSE MCP client.
447
477
 
448
- :returns: Configured HttpMCPClient instance
478
+ :returns: Configured MCPClient instance
449
479
  """
450
- return SSEClient(self.base_url, self.token, self.timeout)
480
+ # Pass the validated SSEServerInfo instance directly
481
+ return SSEClient(server_info=self)
451
482
 
452
483
 
453
484
  @dataclass
@@ -491,7 +522,7 @@ class MCPTool(Tool):
491
522
  # Create tool instance
492
523
  tool = MCPTool(
493
524
  name="add",
494
- server_info=SSEServerInfo(base_url="http://localhost:8000")
525
+ server_info=SSEServerInfo(url="http://localhost:8000/sse")
495
526
  )
496
527
 
497
528
  # Use the tool
@@ -0,0 +1,257 @@
1
+ # SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ from typing import Any
6
+ from urllib.parse import urlparse
7
+
8
+ import httpx
9
+ from exceptiongroup import ExceptionGroup
10
+ from haystack import logging
11
+ from haystack.core.serialization import generate_qualified_class_name, import_class_by_name
12
+ from haystack.tools import Tool, Toolset
13
+
14
+ from .mcp_tool import (
15
+ AsyncExecutor,
16
+ MCPConnectionError,
17
+ MCPServerInfo,
18
+ MCPToolNotFoundError,
19
+ SSEServerInfo,
20
+ StdioServerInfo,
21
+ )
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+
26
+ class MCPToolset(Toolset):
27
+ """
28
+ A Toolset that connects to an MCP (Model Context Protocol) server and provides
29
+ access to its tools.
30
+
31
+ MCPToolset dynamically discovers and loads all tools from any MCP-compliant server,
32
+ supporting both network-based SSE connections and local process-based stdio connections.
33
+ This dual connectivity allows for integrating with both remote and local MCP servers.
34
+
35
+ Example using MCPToolset in a Haystack Pipeline:
36
+ ```python
37
+ # Prerequisites:
38
+ # 1. pip install uvx mcp-server-time # Install required MCP server and tools
39
+ # 2. export OPENAI_API_KEY="your-api-key" # Set up your OpenAI API key
40
+
41
+ import os
42
+ from haystack import Pipeline
43
+ from haystack.components.converters import OutputAdapter
44
+ from haystack.components.generators.chat import OpenAIChatGenerator
45
+ from haystack.components.tools import ToolInvoker
46
+ from haystack.dataclasses import ChatMessage
47
+ from haystack_integrations.tools.mcp import MCPToolset, StdioServerInfo
48
+
49
+ # Create server info for the time service (can also use SSEServerInfo for remote servers)
50
+ server_info = StdioServerInfo(command="uvx", args=["mcp-server-time", "--local-timezone=Europe/Berlin"])
51
+
52
+ # Create the toolset - this will automatically discover all available tools
53
+ # You can optionally specify which tools to include
54
+ mcp_toolset = MCPToolset(
55
+ server_info=server_info,
56
+ tool_names=["get_current_time"] # Only include the get_current_time tool
57
+ )
58
+
59
+ # Create a pipeline with the toolset
60
+ pipeline = Pipeline()
61
+ pipeline.add_component("llm", OpenAIChatGenerator(model="gpt-4o-mini", tools=mcp_toolset))
62
+ pipeline.add_component("tool_invoker", ToolInvoker(tools=mcp_toolset))
63
+ pipeline.add_component(
64
+ "adapter",
65
+ OutputAdapter(
66
+ template="{{ initial_msg + initial_tool_messages + tool_messages }}",
67
+ output_type=list[ChatMessage],
68
+ unsafe=True,
69
+ ),
70
+ )
71
+ pipeline.add_component("response_llm", OpenAIChatGenerator(model="gpt-4o-mini"))
72
+ pipeline.connect("llm.replies", "tool_invoker.messages")
73
+ pipeline.connect("llm.replies", "adapter.initial_tool_messages")
74
+ pipeline.connect("tool_invoker.tool_messages", "adapter.tool_messages")
75
+ pipeline.connect("adapter.output", "response_llm.messages")
76
+
77
+ # Run the pipeline with a user question
78
+ user_input = "What is the time in New York? Be brief."
79
+ user_input_msg = ChatMessage.from_user(text=user_input)
80
+
81
+ result = pipeline.run({"llm": {"messages": [user_input_msg]}, "adapter": {"initial_msg": [user_input_msg]}})
82
+ print(result["response_llm"]["replies"][0].text)
83
+ ```
84
+
85
+ You can also use the toolset via MCP SSE to talk to remote servers:
86
+ ```python
87
+ from haystack_integrations.tools.mcp import MCPToolset, SSEServerInfo
88
+ from haystack.components.tools import ToolInvoker
89
+
90
+ # Create the toolset with an SSE connection
91
+ sse_toolset = MCPToolset(
92
+ server_info=SSEServerInfo(url="http://some-remote-server.com:8000/sse"),
93
+ tool_names=["add", "subtract"] # Only include specific tools
94
+ )
95
+
96
+ # Use the toolset as shown in the pipeline example above
97
+ ```
98
+ """
99
+
100
+ def __init__(
101
+ self,
102
+ server_info: MCPServerInfo,
103
+ tool_names: list[str] | None = None,
104
+ connection_timeout: float = 30.0,
105
+ invocation_timeout: float = 30.0,
106
+ ):
107
+ """
108
+ Initialize the MCP toolset.
109
+
110
+ :param server_info: Connection information for the MCP server
111
+ :param tool_names: Optional list of tool names to include. If provided, only tools with
112
+ matching names will be added to the toolset.
113
+ :param connection_timeout: Timeout in seconds for server connection
114
+ :param invocation_timeout: Default timeout in seconds for tool invocations
115
+ :raises MCPToolNotFoundError: If any of the specified tool names are not found on the server
116
+ """
117
+ # Store configuration
118
+ self.server_info = server_info
119
+ self.tool_names = tool_names
120
+ self.connection_timeout = connection_timeout
121
+ self.invocation_timeout = invocation_timeout
122
+
123
+ # Connect and load tools
124
+ try:
125
+ # Create the appropriate client using the factory method
126
+ client = self.server_info.create_client()
127
+
128
+ # Connect and get available tools using AsyncExecutor
129
+ tools = AsyncExecutor.get_instance().run(client.connect(), timeout=self.connection_timeout)
130
+
131
+ # If tool_names is provided, validate that all requested tools exist
132
+ if self.tool_names:
133
+ available_tools = {tool.name for tool in tools}
134
+ missing_tools = set(self.tool_names) - available_tools
135
+ if missing_tools:
136
+ message = (
137
+ f"The following tools were not found: {', '.join(missing_tools)}. "
138
+ f"Available tools: {', '.join(available_tools)}"
139
+ )
140
+ raise MCPToolNotFoundError(
141
+ message=message, tool_name=next(iter(missing_tools)), available_tools=list(available_tools)
142
+ )
143
+
144
+ # This is a factory that creates the invocation function for the Tool
145
+ def create_invoke_tool(mcp_client, tool_name, tool_timeout):
146
+ def invoke_tool(**kwargs) -> Any:
147
+ """Invoke a tool using the existing client and AsyncExecutor."""
148
+ result = AsyncExecutor.get_instance().run(
149
+ mcp_client.call_tool(tool_name, kwargs), timeout=tool_timeout
150
+ )
151
+ return result
152
+
153
+ return invoke_tool
154
+
155
+ # Create Tool instances not MCPTool for each available tool
156
+ haystack_tools = []
157
+ for tool_info in tools:
158
+ # Skip tools not in the tool_names list if tool_names is provided
159
+ if self.tool_names is not None and tool_info.name not in self.tool_names:
160
+ logger.debug(
161
+ "Skipping tool '{name}' as it's not in the requested tool_names list", name=tool_info.name
162
+ )
163
+ continue
164
+
165
+ # Use the helper function to create the invoke_tool function
166
+ tool = Tool(
167
+ name=tool_info.name,
168
+ description=tool_info.description,
169
+ parameters=tool_info.inputSchema,
170
+ function=create_invoke_tool(client, tool_info.name, self.invocation_timeout),
171
+ )
172
+ haystack_tools.append(tool)
173
+
174
+ # Initialize parent class with complete tools list
175
+ super().__init__(tools=haystack_tools)
176
+
177
+ except Exception as e:
178
+ if isinstance(self.server_info, SSEServerInfo):
179
+ base_message = f"Failed to connect to SSE server at {self.server_info.url}"
180
+ checks = ["1. The server is running"]
181
+
182
+ # Check for ConnectError in exception group or direct exception
183
+ has_connect_error = isinstance(e, httpx.ConnectError) or (
184
+ isinstance(e, ExceptionGroup) and any(isinstance(exc, httpx.ConnectError) for exc in e.exceptions)
185
+ )
186
+
187
+ if has_connect_error:
188
+ # Use urlparse to reliably get scheme, hostname, and port
189
+ parsed_url = urlparse(self.server_info.url)
190
+ port_str = ""
191
+ if parsed_url.port:
192
+ port_str = str(parsed_url.port)
193
+ elif parsed_url.scheme == "http":
194
+ port_str = "80 (default)"
195
+ elif parsed_url.scheme == "https":
196
+ port_str = "443 (default)"
197
+ else:
198
+ port_str = "unknown (scheme not http/https or missing)" # Or handle more schemes if needed
199
+
200
+ # Ensure hostname is handled correctly (it might be None)
201
+ hostname_str = str(parsed_url.hostname) if parsed_url.hostname else "<unknown>"
202
+ message = f"2. The address '{hostname_str}' and port '{port_str}' are correct"
203
+ checks.append(message)
204
+ checks.append("3. There are no firewall or network connectivity issues")
205
+ message = f"{base_message}. Please check if:\n" + "\\n".join(checks)
206
+ else:
207
+ message = f"{base_message}: {e}"
208
+ elif isinstance(self.server_info, StdioServerInfo): # stdio connection
209
+ base_message = "Failed to start MCP server process"
210
+ stdio_info = self.server_info
211
+ args_str = " ".join(stdio_info.args) if stdio_info.args else ""
212
+ cmd = f"{stdio_info.command}{' ' + args_str if args_str else ''}"
213
+ checks = [f"1. The command and arguments are correct (attempted: {cmd})"]
214
+ message = f"{base_message}. Please check if:\n" + "\n".join(checks)
215
+ else:
216
+ message = f"Unsupported server info type: {type(self.server_info)}"
217
+
218
+ raise MCPConnectionError(message=message, server_info=self.server_info, operation="initialize") from e
219
+
220
+ def to_dict(self) -> dict[str, Any]:
221
+ """
222
+ Serialize the MCPToolset to a dictionary.
223
+
224
+ :returns: A dictionary representation of the MCPToolset
225
+ """
226
+ return {
227
+ "type": generate_qualified_class_name(type(self)),
228
+ "data": {
229
+ "server_info": self.server_info.to_dict(),
230
+ "tool_names": self.tool_names,
231
+ "connection_timeout": self.connection_timeout,
232
+ "invocation_timeout": self.invocation_timeout,
233
+ },
234
+ }
235
+
236
+ @classmethod
237
+ def from_dict(cls, data: dict[str, Any]) -> "MCPToolset":
238
+ """
239
+ Deserialize an MCPToolset from a dictionary.
240
+
241
+ :param data: Dictionary representation of the MCPToolset
242
+ :returns: A new MCPToolset instance
243
+ """
244
+ inner_data = data["data"]
245
+
246
+ # Reconstruct the server_info object
247
+ server_info_dict = inner_data.get("server_info", {})
248
+ server_info_class = import_class_by_name(server_info_dict["type"])
249
+ server_info = server_info_class.from_dict(server_info_dict)
250
+
251
+ # Create a new MCPToolset instance
252
+ return cls(
253
+ server_info=server_info,
254
+ tool_names=inner_data.get("tool_names"),
255
+ connection_timeout=inner_data.get("connection_timeout", 30.0),
256
+ invocation_timeout=inner_data.get("invocation_timeout", 30.0),
257
+ )
@@ -132,6 +132,26 @@ class TestMCPServerInfo:
132
132
  assert new_info.token == "test-token"
133
133
  assert new_info.timeout == 45
134
134
 
135
+ def test_url_base_url_validation(self):
136
+ """Test validation of url and base_url parameters."""
137
+ # Test with neither url nor base_url
138
+ with pytest.raises(ValueError, match="Either url or base_url must be provided"):
139
+ SSEServerInfo()
140
+
141
+ # Test with both url and base_url
142
+ with pytest.warns(DeprecationWarning, match="base_url is deprecated"):
143
+ SSEServerInfo(url="http://example.com/sse", base_url="http://example.com")
144
+
145
+ # Test with only url
146
+ server_info = SSEServerInfo(url="http://example.com/sse")
147
+ assert server_info.url == "http://example.com/sse"
148
+ assert server_info.base_url is None
149
+
150
+ # Test with only base_url (deprecated but supported)
151
+ with pytest.warns(DeprecationWarning, match="base_url is deprecated"):
152
+ server_info = SSEServerInfo(base_url="http://example.com")
153
+ assert server_info.base_url == "http://example.com" # Should preserve original base_url
154
+
135
155
  def test_stdio_server_info_serde(self):
136
156
  """Test serialization/deserialization of StdioServerInfo."""
137
157
  server_info = StdioServerInfo(command="python", args=["-m", "mcp_server_time"], env={"TEST_ENV": "value"})
@@ -157,7 +177,7 @@ class TestMCPServerInfo:
157
177
  http_client = http_info.create_client()
158
178
  stdio_client = stdio_info.create_client()
159
179
 
160
- assert http_client.base_url == "http://example.com"
180
+ assert http_client.url == "http://example.com/sse"
161
181
  assert stdio_client.command == "python"
162
182
 
163
183
 
@@ -345,8 +365,8 @@ if __name__ == "__main__":
345
365
  server_process = None
346
366
  try:
347
367
  # Start the server in a separate process
348
- server_process = subprocess.Popen( # noqa: S603
349
- ["python", server_script_path], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True # noqa: S607
368
+ server_process = subprocess.Popen(
369
+ ["python", server_script_path], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True
350
370
  )
351
371
 
352
372
  # Give the server a moment to start
@@ -0,0 +1,296 @@
1
+ import os
2
+ import sys
3
+ import time
4
+ from unittest.mock import AsyncMock, MagicMock, patch
5
+
6
+ import pytest
7
+ import pytest_asyncio
8
+ from haystack.tools import Tool
9
+
10
+ from haystack_integrations.tools.mcp import MCPToolset, SSEServerInfo
11
+ from haystack_integrations.tools.mcp.mcp_tool import MCPConnectionError
12
+
13
+
14
+ @pytest_asyncio.fixture
15
+ async def mock_mcp_toolset():
16
+ """Fixture to create a pre-configured MCPToolset for testing without server connection."""
17
+ mock_tool1 = MagicMock(spec=Tool)
18
+ mock_tool1.name = "tool1"
19
+ mock_tool1.description = "Test tool 1"
20
+ mock_tool1.inputSchema = {"type": "object", "properties": {}}
21
+
22
+ mock_tool2 = MagicMock(spec=Tool)
23
+ mock_tool2.name = "tool2"
24
+ mock_tool2.description = "Test tool 2"
25
+ mock_tool2.inputSchema = {"type": "object", "properties": {}}
26
+
27
+ mock_client = AsyncMock()
28
+ mock_client.connect.return_value = [mock_tool1, mock_tool2]
29
+ mock_client.close = AsyncMock()
30
+
31
+ with (
32
+ patch("haystack_integrations.tools.mcp.mcp_toolset.AsyncExecutor.get_instance") as mock_executor,
33
+ patch("haystack_integrations.tools.mcp.mcp_tool.MCPServerInfo.create_client") as mock_create_client,
34
+ ):
35
+ mock_create_client.return_value = mock_client
36
+ mock_executor.return_value.run.return_value = [mock_tool1, mock_tool2]
37
+
38
+ toolset = MCPToolset(
39
+ server_info=SSEServerInfo(base_url="http://example.com", token="test-token"),
40
+ connection_timeout=45,
41
+ invocation_timeout=60,
42
+ )
43
+
44
+ yield toolset
45
+
46
+ # Cleanup
47
+ await mock_client.close()
48
+
49
+
50
+ @pytest_asyncio.fixture
51
+ async def mock_mcp_toolset_with_tool_names():
52
+ """Fixture to create an MCPToolset with specific tool_names filtering."""
53
+ mock_tool2 = MagicMock(spec=Tool)
54
+ mock_tool2.name = "tool2"
55
+ mock_tool2.description = "Test tool 2"
56
+ mock_tool2.inputSchema = {"type": "object", "properties": {}}
57
+
58
+ mock_client = AsyncMock()
59
+ mock_client.connect.return_value = [mock_tool2]
60
+ mock_client.close = AsyncMock()
61
+
62
+ with (
63
+ patch("haystack_integrations.tools.mcp.mcp_toolset.AsyncExecutor.get_instance") as mock_executor,
64
+ patch("haystack_integrations.tools.mcp.mcp_tool.MCPServerInfo.create_client") as mock_create_client,
65
+ ):
66
+ mock_create_client.return_value = mock_client
67
+ mock_executor.return_value.run.return_value = [mock_tool2]
68
+
69
+ toolset = MCPToolset(
70
+ server_info=SSEServerInfo(base_url="http://example.com", token="test-token"),
71
+ tool_names=["tool2"], # Only include tool2
72
+ connection_timeout=45,
73
+ invocation_timeout=60,
74
+ )
75
+
76
+ yield toolset
77
+
78
+ # Cleanup
79
+ await mock_client.close()
80
+
81
+
82
+ @pytest.mark.asyncio
83
+ class TestMCPToolset:
84
+ """Tests for the MCPToolset class."""
85
+
86
+ async def test_toolset_initialization(self, mock_mcp_toolset):
87
+ """Test if the MCPToolset initializes correctly and loads tools."""
88
+ toolset = mock_mcp_toolset
89
+
90
+ assert isinstance(toolset.server_info, SSEServerInfo)
91
+ assert toolset.connection_timeout == 45
92
+ assert toolset.invocation_timeout == 60
93
+ assert len(toolset) == 2
94
+
95
+ tool_names = [tool.name for tool in toolset.tools]
96
+ assert "tool1" in tool_names
97
+ assert "tool2" in tool_names
98
+
99
+ tool1 = next(tool for tool in toolset.tools if tool.name == "tool1")
100
+ tool2 = next(tool for tool in toolset.tools if tool.name == "tool2")
101
+
102
+ assert tool1.name == "tool1"
103
+ assert tool2.name == "tool2"
104
+ assert tool1.description == "Test tool 1"
105
+ assert tool2.description == "Test tool 2"
106
+
107
+ async def test_toolset_with_filtered_tools(self, mock_mcp_toolset_with_tool_names):
108
+ """Test if the MCPToolset correctly filters tools based on tool_names parameter."""
109
+ toolset = mock_mcp_toolset_with_tool_names
110
+
111
+ # Verify tool_names parameter was stored
112
+ assert toolset.tool_names == ["tool2"]
113
+
114
+ # Verify only the specified tool was added
115
+ assert len(toolset) == 1
116
+
117
+ tool_names = [tool.name for tool in toolset.tools]
118
+ assert "tool1" not in tool_names
119
+ assert "tool2" in tool_names
120
+
121
+ # Check the tool that was included
122
+ tool = toolset.tools[0]
123
+ assert tool.name == "tool2"
124
+ assert tool.description == "Test tool 2"
125
+
126
+ async def test_toolset_serde(self, mock_mcp_toolset):
127
+ """Test serialization and deserialization of MCPToolset."""
128
+ toolset = mock_mcp_toolset
129
+
130
+ toolset_dict = toolset.to_dict()
131
+ assert toolset_dict["type"] == "haystack_integrations.tools.mcp.mcp_toolset.MCPToolset"
132
+ assert toolset_dict["data"]["connection_timeout"] == 45
133
+ assert toolset_dict["data"]["invocation_timeout"] == 60
134
+ assert toolset_dict["data"]["server_info"]["base_url"] == "http://example.com"
135
+ assert toolset_dict["data"]["tool_names"] is None
136
+
137
+ with patch("haystack_integrations.tools.mcp.mcp_toolset.MCPToolset.__init__", return_value=None) as mock_init:
138
+ MCPToolset.from_dict(toolset_dict)
139
+
140
+ mock_init.assert_called_once()
141
+ _, kwargs = mock_init.call_args
142
+ assert kwargs["connection_timeout"] == 45
143
+ assert kwargs["invocation_timeout"] == 60
144
+ assert kwargs["tool_names"] is None
145
+ assert isinstance(kwargs["server_info"], SSEServerInfo)
146
+ assert kwargs["server_info"].base_url == "http://example.com"
147
+
148
+ async def test_toolset_serde_with_tool_names(self, mock_mcp_toolset_with_tool_names):
149
+ """Test serialization and deserialization of MCPToolset with tool_names parameter."""
150
+ toolset = mock_mcp_toolset_with_tool_names
151
+
152
+ toolset_dict = toolset.to_dict()
153
+ assert toolset_dict["type"] == "haystack_integrations.tools.mcp.mcp_toolset.MCPToolset"
154
+ assert toolset_dict["data"]["tool_names"] == ["tool2"]
155
+
156
+ with patch("haystack_integrations.tools.mcp.mcp_toolset.MCPToolset.__init__", return_value=None) as mock_init:
157
+ MCPToolset.from_dict(toolset_dict)
158
+
159
+ mock_init.assert_called_once()
160
+ _, kwargs = mock_init.call_args
161
+ assert kwargs["tool_names"] == ["tool2"]
162
+
163
+ async def test_toolset_combination(self, mock_mcp_toolset):
164
+ """Test combining MCPToolset with other tools."""
165
+ toolset = mock_mcp_toolset
166
+
167
+ def add(a: int, b: int) -> int:
168
+ """Add two numbers"""
169
+ return a + b
170
+
171
+ add_tool = Tool(
172
+ name="add",
173
+ description="Add two numbers",
174
+ function=add,
175
+ parameters={
176
+ "type": "object",
177
+ "properties": {"a": {"type": "integer"}, "b": {"type": "integer"}},
178
+ "required": ["a", "b"],
179
+ },
180
+ )
181
+
182
+ combined_tools = toolset + [add_tool]
183
+
184
+ assert len(combined_tools) == 3
185
+
186
+ tool_names = [tool.name for tool in combined_tools.tools]
187
+ assert "tool1" in tool_names
188
+ assert "tool2" in tool_names
189
+ assert "add" in tool_names
190
+
191
+ async def test_toolset_error_handling(self):
192
+ """Test error handling during toolset initialization."""
193
+ with pytest.raises(MCPConnectionError):
194
+ MCPToolset(
195
+ server_info=SSEServerInfo(base_url="http://example.com"),
196
+ connection_timeout=30.0,
197
+ invocation_timeout=30.0,
198
+ )
199
+
200
+
201
+ @pytest.mark.integration
202
+ class TestMCPToolsetIntegration:
203
+ """Integration tests for MCPToolset."""
204
+
205
+ @pytest.mark.skipif(sys.platform == "win32", reason="Windows fails for some reason")
206
+ def test_toolset_with_sse_connection(self):
207
+ """Test MCPToolset with an SSE connection to a simple server."""
208
+ import socket
209
+ import subprocess
210
+ import tempfile
211
+
212
+ # Find an available port
213
+ def find_free_port():
214
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
215
+ s.bind(("", 0))
216
+ return s.getsockname()[1]
217
+
218
+ port = find_free_port()
219
+
220
+ # Create a temporary file for the server script
221
+ with tempfile.NamedTemporaryFile(suffix=".py", delete=False) as temp_file:
222
+ temp_file.write(
223
+ f"""
224
+ from mcp.server.fastmcp import FastMCP
225
+ mcp = FastMCP("MCP Calculator", host="127.0.0.1", port={port})
226
+
227
+ @mcp.tool()
228
+ def add(a: int, b: int) -> int:
229
+ \"\"\"Add two numbers\"\"\"
230
+ return a + b
231
+
232
+ @mcp.tool()
233
+ def subtract(a: int, b: int) -> int:
234
+ \"\"\"Subtract b from a\"\"\"
235
+ return a - b
236
+
237
+ if __name__ == "__main__":
238
+ try:
239
+ mcp.run(transport="sse")
240
+ except Exception as e:
241
+ sys.exit(1)
242
+ """.encode()
243
+ )
244
+ server_script_path = temp_file.name
245
+
246
+ server_process = None
247
+ try:
248
+ # Start the server in a separate process
249
+ server_process = subprocess.Popen(
250
+ ["python", server_script_path], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True
251
+ )
252
+
253
+ # Give the server a moment to start
254
+ time.sleep(2)
255
+
256
+ # Create the toolset
257
+ server_info = SSEServerInfo(base_url=f"http://127.0.0.1:{port}")
258
+ toolset = MCPToolset(server_info=server_info)
259
+
260
+ # Verify we got both tools
261
+ assert len(toolset) == 2
262
+
263
+ tool_names = [tool.name for tool in toolset.tools]
264
+ assert "add" in tool_names
265
+ assert "subtract" in tool_names
266
+
267
+ # Test the add tool
268
+ add_tool = next(tool for tool in toolset.tools if tool.name == "add")
269
+ result = add_tool.invoke(a=5, b=3)
270
+ assert result.content[0].text == "8"
271
+
272
+ # Test the subtract tool
273
+ subtract_tool = next(tool for tool in toolset.tools if tool.name == "subtract")
274
+ result = subtract_tool.invoke(a=10, b=4)
275
+ assert result.content[0].text == "6"
276
+
277
+ except Exception:
278
+ # Check server output for clues
279
+ if server_process and server_process.poll() is None:
280
+ server_process.terminate()
281
+ raise
282
+
283
+ finally:
284
+ # Clean up
285
+ if server_process:
286
+ if server_process.poll() is None: # Process is still running
287
+ server_process.terminate()
288
+ try:
289
+ server_process.wait(timeout=5)
290
+ except subprocess.TimeoutExpired:
291
+ server_process.kill()
292
+ server_process.wait(timeout=5)
293
+
294
+ # Remove the temporary file
295
+ if os.path.exists(server_script_path):
296
+ os.remove(server_script_path)
@@ -1,9 +0,0 @@
1
- # Changelog
2
-
3
- ## [integrations/mcp-v0.0.1] - 2025-03-12
4
-
5
- ### 🚀 Features
6
-
7
- - Add MCP tooling integration (#1487)
8
-
9
- <!-- generated by git-cliff -->
@@ -1,82 +0,0 @@
1
- # SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
2
- #
3
- # SPDX-License-Identifier: Apache-2.0
4
-
5
- """
6
- Example of a Hayhooks PipelineWrapper for deploying an MCP Tool-based time pipeline as a REST API.
7
-
8
- To run this example:
9
-
10
- 1. Install Hayhooks and dependencies:
11
- $ pip install hayhooks haystack-ai
12
-
13
- 2. Start the Hayhooks server:
14
- $ hayhooks run
15
-
16
- 3. Deploy this pipeline wrapper:
17
- $ hayhooks pipeline deploy-files -n time_pipeline {root_dir_for_mcp_haystack_integration}/examples/hayhooks/
18
-
19
- 4. Invoke via curl:
20
- $ curl -X POST 'http://localhost:1416/time_pipeline/run' -H 'accept: application/json' -H 'Content-Type: application/json' -d '{"query":"What is the time in San Francisco? Be brief"}'
21
-
22
- For more information, see: https://github.com/deepset-ai/hayhooks
23
- """
24
-
25
- from hayhooks import BasePipelineWrapper
26
- from haystack import Pipeline
27
- from haystack.components.converters import OutputAdapter
28
- from haystack.components.generators.chat import OpenAIChatGenerator
29
- from haystack.components.tools import ToolInvoker
30
- from haystack.dataclasses import ChatMessage
31
-
32
- from haystack_integrations.tools.mcp.mcp_tool import MCPTool, StdioServerInfo
33
-
34
-
35
- class PipelineWrapper(BasePipelineWrapper):
36
- def setup(self) -> None:
37
- """
38
- Setup the pipeline with MCP time tool.
39
-
40
- This creates a pipeline that uses an MCP time tool to get the current time
41
- and then uses the time to answer a user question.
42
- """
43
-
44
- time_tool = MCPTool(
45
- name="get_current_time",
46
- server_info=StdioServerInfo(command="uvx", args=["mcp-server-time", "--local-timezone=Europe/Berlin"]),
47
- )
48
-
49
- self.pipeline = Pipeline()
50
- self.pipeline.add_component("llm", OpenAIChatGenerator(model="gpt-4o-mini", tools=[time_tool]))
51
- self.pipeline.add_component("tool_invoker", ToolInvoker(tools=[time_tool]))
52
- self.pipeline.add_component(
53
- "adapter",
54
- OutputAdapter(
55
- template="{{ initial_msg + initial_tool_messages + tool_messages }}",
56
- output_type=list[ChatMessage],
57
- unsafe=True,
58
- ),
59
- )
60
- self.pipeline.add_component("response_llm", OpenAIChatGenerator(model="gpt-4o-mini"))
61
- self.pipeline.connect("llm.replies", "tool_invoker.messages")
62
- self.pipeline.connect("llm.replies", "adapter.initial_tool_messages")
63
- self.pipeline.connect("tool_invoker.tool_messages", "adapter.tool_messages")
64
- self.pipeline.connect("adapter.output", "response_llm.messages")
65
-
66
- def run_api(self, query: str) -> str:
67
- """
68
- Run the pipeline with a user query.
69
-
70
- :param query: The user query asking about time
71
- :return: The response from the LLM
72
- """
73
- # Create a user message from the query
74
- user_input_msg = ChatMessage.from_user(text=query)
75
-
76
- # Run the pipeline
77
- result = self.pipeline.run(
78
- {"llm": {"messages": [user_input_msg]}, "adapter": {"initial_msg": [user_input_msg]}}
79
- )
80
-
81
- # Return the text of the first reply
82
- return result["response_llm"]["replies"][0].text
File without changes
File without changes
File without changes