Skip to content

Conversation

@vblagoje
Copy link
Member

@vblagoje vblagoje commented Oct 14, 2025

Why:

Separates pipeline validation from MCP server connection. Allows pipelines to validate successfully without requiring server connectivity, then connect during pipeline warm_up.

Keep in draft stage until deepset-ai/haystack#9856 is merge and Haystack 2.19 released.

What:

  • Added eager_connect parameter to MCPTool and MCPToolset (defaults to False)
  • Implemented _ensure_connected() with thread-safe lazy initialization
  • Added warm_up() methods for connecting during pipeline warm_up phase

How can it be used:

# Validate pipeline without connecting to MCP server
tool = MCPTool(name="add", server_info=info, eager_connect=False)
pipeline.add_component("invoker", ToolInvoker(tools=[tool]))  # ✓ Validates

# Connect during pipeline warm_up
pipeline.run()  # triggers tool.warm_up() → connects to server

How did you test it:

  • Test verifies permissive schema → strict schema transition on first use
  • Confirmed backward compatibility (eager_connect=True by default)
  • Tested with itinerary agent

Notes for the reviewer:

  • Placeholder schema allows validation; strict schema loaded post-connection
  • warm_up() is idempotent

@github-actions github-actions bot added integration:mcp type:documentation Improvements or additions to documentation labels Oct 14, 2025
@vblagoje
Copy link
Member Author

vblagoje commented Oct 21, 2025

A few notes:

eager_connect defaults to False so no-code platforms don't have to specify each mcp tool/toolset as eager_connect false. With Tool.warm_up()/Toolset.warm_up() in place in Haystack 2.19 users that prefer eager tool init can still opt in explicitly and long-term default being False makes more sense. (I'll add this explicitly in release notes)

I used threading.RLock() as _ensure_connected() mutates important shared state (_client, _worker, schema) and can be entered from synchronous invoke, warm_up, and async call dispatched from different threads or loops. This re-entrant lock is cheap insurance against double-connect races, leaked session managers and incorrect tool parameters fetched and overwritten.

@vblagoje vblagoje marked this pull request as ready for review October 21, 2025 08:27
@vblagoje vblagoje requested a review from a team as a code owner October 21, 2025 08:27
@vblagoje vblagoje requested review from mpangrazzi and sjrl and removed request for a team October 21, 2025 08:27
@vblagoje
Copy link
Member Author

vblagoje commented Oct 21, 2025

@mpangrazzi I also added @sjrl for review because he has more context about the whole Tool(set) warm_up initiative. I also want to update examples to showcase more use of Agent and warm_up but I left that change for separate PR for easier Pr digest - I have the changes locally and will open a separate PR for examples.

Comment on lines 926 to 928
# Connect on first use if eager_connect is turned off
if not self._eager_connect:
self._ensure_connected()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't need this right since we would expect a user to connect via warm_up?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

At the very least if we do want this behavior I think we should just do

Suggested change
# Connect on first use if eager_connect is turned off
if not self._eager_connect:
self._ensure_connected()
# Connect on first use if eager_connect is turned off
self.warm_up()

and let warm_up handle this if logic

Comment on lines 965 to 966
if not self._eager_connect:
self._ensure_connected()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here if we want this behavior just call warm_up

Suggested change
if not self._eager_connect:
self._ensure_connected()
self.warm_up()

return
self._ensure_connected()

def _ensure_connected(self) -> None:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we end up just calling warm_up like I suggest in my above comments let's remove this function _ensure_connected and just directly put all its logic into warm_up

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok yes, i think it was a remnant of previous work where I also wanted to reconnect when broken connection was detected. Let me double check, thanks for raising it

@vblagoje vblagoje requested a review from sjrl October 22, 2025 09:20
Comment on lines 932 to 933
if self._client is None:
raise MCPConnectionError(message="Not connected to an MCP server", operation="call_tool")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it be better to use an ignore or an assert statement since we know this error can never be raised?

try:
return await asyncio.wait_for(self._client.call_tool(self.name, kwargs), timeout=self._invocation_timeout)
self.warm_up()
client = cast(MCPClient, self._client)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems like you are using a different approach here to get around the mypy error than here. Let's make it consistent.

Comment on lines 978 to 979
if self._eager_connect:
return
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not a big deal but this isn't needed really since the check below

            if self._client is not None:
                return

will also just return early right?

Comment on lines 980 to 997
with self._lock:
if self._client is not None:
return
client = self._server_info.create_client()
worker = _MCPClientSessionManager(client, timeout=self._connection_timeout)
tools = worker.tools()
tool = next((t for t in tools if t.name == self.name), None)
if tool is None:
available = [t.name for t in tools]
msg = f"Tool '{self.name}' not found on server. Available tools: {', '.join(available)}"
raise MCPToolNotFoundError(msg, tool_name=self.name, available_tools=available)
# Publish connection and tighten parameters for better prompting
self._client = client
self._worker = worker
try:
self.parameters = tool.inputSchema
except Exception as e:
logger.debug(f"TOOL: Could not update strict parameters after connect: {e!s}")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey I'm noticing that similar code to this lives in the init method starting at current line 855. Could we make a shared method that get's re-used in both places rather than having duplicate code?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If possible, one way could be to just call self.warm_up in the init method if self._eager_connect is True

self.connection_timeout = connection_timeout
self.invocation_timeout = invocation_timeout
self.eager_connect = eager_connect
self.warmup_called = False
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I'd make the warmup_called variable private

Comment on lines 161 to 162
Call this before handing the toolset to components like ``ToolInvoker`` so that
each tool's schema is available without performing a real invocation.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a bit misleading since users could also:

  • Call ToolInvoker.warm_up() instead of MCPToolset.warm_up()
  • Call Pipeline.warm_up() if the ToolInvoker is part of a pipeline

Comment on lines 164 to 165
if self.eager_connect or self.warmup_called:
return
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we make the change in this comment https://github.com/deepset-ai/haystack-core-integrations/pull/2384/files#r2451228527 then we can update this to

Suggested change
if self.eager_connect or self.warmup_called:
return
if self.warmup_called:
return

@vblagoje
Copy link
Member Author

Great feedback @sjrl - I also tested this latest commit against itinerary agent

@vblagoje vblagoje requested a review from sjrl October 22, 2025 13:03
Copy link
Contributor

@sjrl sjrl left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good!

@vblagoje vblagoje merged commit aa8f5a4 into main Oct 22, 2025
11 checks passed
@vblagoje vblagoje deleted the mcp_warmup branch October 22, 2025 13:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

integration:mcp type:documentation Improvements or additions to documentation

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants