From a7ef790ad2986ea9223bec1bfcaa031c6e56e0b3 Mon Sep 17 00:00:00 2001 From: Hrushi11 Date: Sun, 10 May 2026 21:42:55 +0530 Subject: [PATCH] mcp tool annotation support in hayhooks --- docs/concepts/pipeline-wrapper.md | 26 ++++++++++++++++ docs/concepts/yaml-pipeline-deployment.md | 12 ++++++++ docs/features/mcp-support.md | 24 +++++++++++++++ .../server/utils/base_pipeline_wrapper.py | 11 +++++++ src/hayhooks/server/utils/deploy_utils.py | 11 +++++++ src/hayhooks/server/utils/mcp_utils.py | 19 ++++++++++-- tests/test_deploy_utils.py | 21 +++++++++++++ tests/test_deploy_yaml.py | 30 +++++++++++++++++++ .../pipeline_wrapper.py | 23 ++++++++++++++ tests/test_it_mcp_server.py | 6 ++++ tests/test_mcp.py | 29 ++++++++++++++++++ 11 files changed, 210 insertions(+), 2 deletions(-) create mode 100644 tests/test_files/files/chat_with_website_mcp_hints/pipeline_wrapper.py diff --git a/docs/concepts/pipeline-wrapper.md b/docs/concepts/pipeline-wrapper.md index 2bbad910..2f0f97be 100644 --- a/docs/concepts/pipeline-wrapper.md +++ b/docs/concepts/pipeline-wrapper.md @@ -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: diff --git a/docs/concepts/yaml-pipeline-deployment.md b/docs/concepts/yaml-pipeline-deployment.md index 0303b081..95d99588 100644 --- a/docs/concepts/yaml-pipeline-deployment.md +++ b/docs/concepts/yaml-pipeline-deployment.md @@ -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 diff --git a/docs/features/mcp-support.md b/docs/features/mcp-support.md index 69ad4689..64e3bb35 100644 --- a/docs/features/mcp-support.md +++ b/docs/features/mcp-support.md @@ -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:** @@ -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:** @@ -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) @@ -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 diff --git a/src/hayhooks/server/utils/base_pipeline_wrapper.py b/src/hayhooks/server/utils/base_pipeline_wrapper.py index 5af8b0bb..367c321a 100644 --- a/src/hayhooks/server/utils/base_pipeline_wrapper.py +++ b/src/hayhooks/server/utils/base_pipeline_wrapper.py @@ -1,5 +1,14 @@ 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): @@ -7,6 +16,8 @@ class BasePipelineWrapper(ABC): # 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 diff --git a/src/hayhooks/server/utils/deploy_utils.py b/src/hayhooks/server/utils/deploy_utils.py index 83d33b21..852ec5d0 100644 --- a/src/hayhooks/server/utils/deploy_utils.py +++ b/src/hayhooks/server/utils/deploy_utils.py @@ -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 @@ -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) @@ -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, @@ -713,6 +722,7 @@ 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 = { @@ -720,6 +730,7 @@ def prepare_pipeline_yaml( "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) diff --git a/src/hayhooks/server/utils/mcp_utils.py b/src/hayhooks/server/utils/mcp_utils.py index 16072336..896bb4b8 100644 --- a/src/hayhooks/server/utils/mcp_utils.py +++ b/src/hayhooks/server/utils/mcp_utils.py @@ -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: @@ -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]) diff --git a/tests/test_deploy_utils.py b/tests/test_deploy_utils.py index 1869b946..a4db9485 100644 --- a/tests/test_deploy_utils.py +++ b/tests/test_deploy_utils.py @@ -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 = """ diff --git a/tests/test_deploy_yaml.py b/tests/test_deploy_yaml.py index 40df5a04..cb8954ab 100644 --- a/tests/test_deploy_yaml.py +++ b/tests/test_deploy_yaml.py @@ -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, + } diff --git a/tests/test_files/files/chat_with_website_mcp_hints/pipeline_wrapper.py b/tests/test_files/files/chat_with_website_mcp_hints/pipeline_wrapper.py new file mode 100644 index 00000000..6f68f5fd --- /dev/null +++ b/tests/test_files/files/chat_with_website_mcp_hints/pipeline_wrapper.py @@ -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" diff --git a/tests/test_it_mcp_server.py b/tests/test_it_mcp_server.py index 4ded6888..6d9218e1 100644 --- a/tests/test_it_mcp_server.py +++ b/tests/test_it_mcp_server.py @@ -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 diff --git a/tests/test_mcp.py b/tests/test_mcp.py index 675188e6..b3f07f36 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -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() @@ -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 @@ -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()