Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions docs/concepts/pipeline-wrapper.md
Original file line number Diff line number Diff line change
Expand Up @@ -889,6 +889,32 @@ def run_api(self, urls: list[str], question: str) -> str:
return result["llm"]["replies"][0]
```

### MCP Tool Hints

Use `tool_hints` to add MCP tool annotations for clients such as Claude Desktop:

```python
class PipelineWrapper(BasePipelineWrapper):
tool_hints = {
"title": "Website Q&A",
"readOnly": True,
"destructive": False,
"idempotent": True,
"openWorld": True,
}

def setup(self) -> None:
...
```

If omitted, Hayhooks applies safe defaults for pipeline tools:

- `title`: title-cased pipeline name
- `readOnly`: `True`
- `destructive`: `False`
- `idempotent`: `True`
- `openWorld`: `True`

## Examples

For complete, working examples see:
Expand Down
12 changes: 12 additions & 0 deletions docs/concepts/yaml-pipeline-deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,18 @@ outputs:
replies: llm.replies
```

You can optionally add MCP tool hints inside the YAML metadata block:

```yaml
metadata:
tool_hints:
title: Website Q&A
readOnly: true
destructive: false
idempotent: true
openWorld: true
```

### Key Requirements

1. **`inputs` Section**: Maps friendly names to pipeline component fields
Expand Down
24 changes: 24 additions & 0 deletions docs/features/mcp-support.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ A [MCP Tool](https://modelcontextprotocol.io/docs/concepts/tools) requires:
- `name`: The name of the tool
- `description`: The description of the tool
- `inputSchema`: JSON Schema describing the tool's input parameters
- `annotations`: Optional hints describing tool behavior to MCP clients

**How Hayhooks Creates MCP Tools:**

Expand All @@ -130,6 +131,7 @@ For each deployed pipeline, Hayhooks will:
- If you use Google-style or reStructuredText-style docstrings, use the first line as MCP Tool `description` and the rest as `parameters` (if present)
- Each parameter description will be used as the `description` of the corresponding Pydantic model field (if present)
- Generate a Pydantic model from the `inputSchema` using the **`run_api` method arguments as fields**
- Build MCP `ToolAnnotations` from optional pipeline `tool_hints`

**Example:**

Expand All @@ -140,6 +142,14 @@ from hayhooks import BasePipelineWrapper


class PipelineWrapper(BasePipelineWrapper):
tool_hints = {
"title": "Website Q&A",
"readOnly": True,
"destructive": False,
"idempotent": True,
"openWorld": True,
}

def setup(self) -> None:
pipeline_yaml = (Path(__file__).parent / "chat_with_website.yml").read_text()
self.pipeline = Pipeline.loads(pipeline_yaml)
Expand All @@ -159,6 +169,20 @@ class PipelineWrapper(BasePipelineWrapper):

YAML-deployed pipelines are also automatically exposed as MCP tools. When you deploy via `hayhooks pipeline deploy-yaml`, the pipeline becomes available as an MCP tool with its input schema derived from the YAML `inputs` section.

You can also declare MCP tool hints directly in the YAML `metadata` block:

```yaml
metadata:
tool_hints:
title: Calculator
readOnly: true
destructive: false
idempotent: true
openWorld: false
```

If `tool_hints` is omitted, Hayhooks uses these defaults for pipeline tools: title-cased pipeline name, `readOnly=true`, `destructive=false`, `idempotent=true`, and `openWorld=true`.

For complete examples and detailed information, see [YAML Pipeline Deployment](../concepts/yaml-pipeline-deployment.md).

### Skip MCP Tool Listing
Expand Down
11 changes: 11 additions & 0 deletions src/hayhooks/server/utils/base_pipeline_wrapper.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,23 @@
from abc import ABC, abstractmethod
from collections.abc import AsyncGenerator, Generator
from typing import TypedDict


class MCPToolHints(TypedDict, total=False):
title: str
readOnly: bool
destructive: bool
idempotent: bool
openWorld: bool


class BasePipelineWrapper(ABC):
# Class attribute to skip MCP listing of the pipeline
# If True, the pipeline will not be listed as an MCP tool
# Even if it has a description and a request model
skip_mcp: bool = False
# Optional MCP tool annotations hints used when exposing this pipeline as a tool.
tool_hints: MCPToolHints = {}

def __init__(self):
self.pipeline = None
Expand Down
11 changes: 11 additions & 0 deletions src/hayhooks/server/utils/deploy_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
unload_pipeline_modules,
)
from hayhooks.server.utils.streaming_response_utils import _streaming_response_from_result
from hayhooks.server.utils.yaml_utils import parse_yaml_pipeline
from hayhooks.server.utils.yaml_pipeline_wrapper import YAMLPipelineWrapper
from hayhooks.settings import DeployConcurrencyPolicy, settings

Expand Down Expand Up @@ -618,6 +619,7 @@ def _register_prepared_pipeline(
"description": description,
"request_model": request_model,
"skip_mcp": pipeline_wrapper.skip_mcp,
"tool_hints": getattr(pipeline_wrapper, "tool_hints", {}) or {},
}

# Merge extra metadata (e.g., YAML-specific fields)
Expand Down Expand Up @@ -695,6 +697,13 @@ def prepare_pipeline_yaml(
save_file: bool = True if options is None else bool(options.get("save_file", True))
description = (options or {}).get("description")
skip_mcp = bool((options or {}).get("skip_mcp", False))
tool_hints = (options or {}).get("tool_hints")
if tool_hints is None:
# Only parse YAML metadata when tool_hints is not provided via options.
# YAMLPipelineWrapper.from_yaml() will parse the full YAML again later,
# but parse_yaml_pipeline is lightweight (just yaml.safe_load).
yaml_metadata = parse_yaml_pipeline(source_code).get("metadata", {}) or {}
tool_hints = yaml_metadata.get("tool_hints", {}) or {}

with trace_operation(
SPAN_PIPELINE_DEPLOY_PREPARE,
Expand All @@ -713,13 +722,15 @@ def prepare_pipeline_yaml(

pipeline_wrapper = YAMLPipelineWrapper.from_yaml(source_code, description=description)
pipeline_wrapper.skip_mcp = skip_mcp
pipeline_wrapper.tool_hints = tool_hints
pipeline_wrapper.setup()

extra_metadata = {
"description": description or pipeline_name,
"streaming_components": pipeline_wrapper.streaming_components,
"include_outputs_from": pipeline_wrapper.include_outputs_from,
"input_resolutions": pipeline_wrapper.input_resolutions,
"tool_hints": tool_hints,
}

return PreparedPipeline(name=pipeline_name, wrapper=pipeline_wrapper, extra_metadata=extra_metadata)
Expand Down
19 changes: 17 additions & 2 deletions src/hayhooks/server/utils/mcp_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ class CoreTools(str, Enum):
from mcp.server import Server
from mcp.server.sse import SseServerTransport
from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
from mcp.types import TextContent, Tool
from mcp.types import TextContent, Tool, ToolAnnotations


def deploy_pipelines() -> None:
Expand Down Expand Up @@ -132,14 +132,29 @@ async def list_pipelines_as_tools() -> list["Tool"]:
log.debug("Skipping pipeline '{}' as it has skip_mcp set to True", pipeline_name)
continue

hints = metadata.get("tool_hints", {}) or {}
annotations = ToolAnnotations(
title=hints.get("title", pipeline_name.replace("_", " ").title()),
readOnlyHint=hints.get("readOnly", True),
destructiveHint=hints.get("destructive", False),
idempotentHint=hints.get("idempotent", True),
openWorldHint=hints.get("openWorld", True),
)

tools.append(
Tool(
name=pipeline_name,
description=metadata.get("description", ""),
inputSchema=metadata["request_model"].model_json_schema(),
annotations=annotations,
)
)
log.debug("Added pipeline as MCP tool '{}' with description: '{}'", pipeline_name, metadata["description"])
log.debug(
"Added pipeline as MCP tool '{}' with description '{}' and annotations {}",
pipeline_name,
metadata.get("description", ""),
annotations.model_dump(exclude_none=True),
)

log.debug("Pipelines listed as MCP tools: {}", [tool.name for tool in tools])

Expand Down
21 changes: 21 additions & 0 deletions tests/test_deploy_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -689,6 +689,27 @@ def test_deploy_pipeline_files_skip_mcp(mocker):
assert registry.get_metadata("chat_with_website_mcp_skip").get("skip_mcp") is True


def test_deploy_pipeline_files_stores_tool_hints(mocker):
mock_app = mocker.Mock()
mock_app.routes = []

test_file_path = Path("tests/test_files/files/chat_with_website_mcp_hints/pipeline_wrapper.py")
files = {"pipeline_wrapper.py": test_file_path.read_text()}

result = deploy_pipeline_files(
app=mock_app, pipeline_name="chat_with_website_mcp_hints", files=files, save_files=False
)
assert result == {"name": "chat_with_website_mcp_hints"}

assert registry.get_metadata("chat_with_website_mcp_hints").get("tool_hints") == {
"title": "Website Q&A",
"readOnly": True,
"destructive": False,
"idempotent": True,
"openWorld": False,
}


def test_deploy_pipeline_files_overwrite_preserves_new_sibling_files(test_settings):
pipeline_name = "wrapper_with_sibling_file"
wrapper_source = """
Expand Down
30 changes: 30 additions & 0 deletions tests/test_deploy_yaml.py
Original file line number Diff line number Diff line change
Expand Up @@ -164,3 +164,33 @@ def test_deploy_yaml_pipeline_with_streaming_components_all_keyword():

# Verify the wrapper has an AsyncPipeline internally
assert isinstance(wrapper.pipeline, AsyncPipeline)


def test_deploy_yaml_pipeline_reads_tool_hints_from_metadata():
pipeline_file = Path(__file__).parent / "test_files/yaml/sample_calc_pipeline.yml"
source_code = pipeline_file.read_text().replace(
"metadata: {}",
"""metadata:
tool_hints:
title: Calculator
readOnly: true
destructive: false
idempotent: true
openWorld: false""",
)

deploy_pipeline_yaml(
pipeline_name="calc_with_hints",
source_code=source_code,
options={"save_file": False},
)

metadata = registry.get_metadata("calc_with_hints")
assert metadata is not None
assert metadata["tool_hints"] == {
"title": "Calculator",
"readOnly": True,
"destructive": False,
"idempotent": True,
"openWorld": False,
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
from haystack import Pipeline

from hayhooks import BasePipelineWrapper, log


class PipelineWrapper(BasePipelineWrapper):
tool_hints = {
"title": "Website Q&A",
"readOnly": True,
"destructive": False,
"idempotent": True,
"openWorld": False,
}

def setup(self) -> None:
self.pipeline = Pipeline()

def run_api(self, urls: list[str], question: str) -> str:
"""
Ask a question about one or more websites using a Haystack pipeline.
"""
log.trace("Running pipeline with urls: {} and question: {}", urls, question)
return "This is a mock response from the pipeline"
6 changes: 6 additions & 0 deletions tests/test_it_mcp_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,12 @@ async def test_list_tools_with_one_pipeline_deployed(mcp_server_instance, deploy
"title": "chat_with_websiteRunRequest",
"type": "object",
}
assert pipeline_tool.annotations is not None
assert pipeline_tool.annotations.title == "Chat With Website"
assert pipeline_tool.annotations.readOnlyHint is True
assert pipeline_tool.annotations.destructiveHint is False
assert pipeline_tool.annotations.idempotentHint is True
assert pipeline_tool.annotations.openWorldHint is True


@pytest.mark.asyncio
Expand Down
29 changes: 29 additions & 0 deletions tests/test_mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,15 @@ async def deploy_chat_with_website_mcp_skip():
deploy_pipeline_files(pipeline_name="chat_with_website_mcp_skip", files=files, save_files=False)


@pytest.fixture
async def deploy_chat_with_website_mcp_hints():
pipeline_wrapper_path = Path("tests/test_files/files/chat_with_website_mcp_hints/pipeline_wrapper.py")
files = {
"pipeline_wrapper.py": await pipeline_wrapper_path.read_text(),
}
deploy_pipeline_files(pipeline_name="chat_with_website_mcp_hints", files=files, save_files=False)


@pytest.mark.asyncio
async def test_list_pipelines_as_tools_no_pipelines():
tools = await list_pipelines_as_tools()
Expand All @@ -72,6 +81,12 @@ async def test_list_pipelines_as_tools(deploy_chat_with_website_mcp):
"title": "chat_with_websiteRunRequest",
"type": "object",
}
assert tools[0].annotations is not None
assert tools[0].annotations.title == "Chat With Website"
assert tools[0].annotations.readOnlyHint is True
assert tools[0].annotations.destructiveHint is False
assert tools[0].annotations.idempotentHint is True
assert tools[0].annotations.openWorldHint is True


@pytest.mark.asyncio
Expand Down Expand Up @@ -127,6 +142,20 @@ async def test_skip_pipeline_from_mcp_listing(deploy_chat_with_website_mcp_skip)
assert len(tools) == 0


@pytest.mark.asyncio
async def test_pipeline_tool_hints_override_default_annotations(deploy_chat_with_website_mcp_hints):
tools = await list_pipelines_as_tools()

assert len(tools) == 1
assert tools[0].name == "chat_with_website_mcp_hints"
assert tools[0].annotations is not None
assert tools[0].annotations.title == "Website Q&A"
assert tools[0].annotations.readOnlyHint is True
assert tools[0].annotations.destructiveHint is False
assert tools[0].annotations.idempotentHint is True
assert tools[0].annotations.openWorldHint is False


@pytest.mark.asyncio
async def test_list_core_tools():
tools = await list_core_tools()
Expand Down