Skip to content

Commit b750f7a

Browse files
authored
feat: [ECO-200] - Add Search history tool (#204)
* feat: [ECO-200] - Add Search history tool * fix: linting * fix: tests * fix: format * feat: add readme how to run locally * fix: tests & unused comment * fix: linting * fix: format * fix: add timeout to search history request
1 parent f089d6b commit b750f7a

12 files changed

Lines changed: 603 additions & 3 deletions

File tree

README.md

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,37 @@ pipx install uv
2929
uv sync --locked --all-extras --all-groups
3030
```
3131

32+
### Local Development
33+
34+
If you want to test your changes locally, follow these steps:
35+
36+
1. Add a script run-deepset-mcp.sh that uses the binary from the project's virtual env
37+
38+
```bash
39+
#!/usr/bin/env bash
40+
# Wrapper to run the local deepset-mcp server for Cursor MCP.
41+
# Use this as command so it doesn't depend on uv or PATH.
42+
set -e
43+
cd "$(dirname "$0")"
44+
exec .venv/bin/deepset-mcp
45+
```
46+
47+
2. Use it this way in Cursor:
48+
49+
```bash
50+
"deepset": {
51+
"command": "/bin/bash",
52+
"args": ["/Users/*****/****/deepset-mcp-server/run-deepset-mcp.sh"],
53+
"cwd": "/Users/*****/****/deepset-mcp-server",
54+
"env": {
55+
"DEEPSET_WORKSPACE": "WORKSPACE",
56+
"DEEPSET_API_KEY": "API_KEY"
57+
}
58+
}
59+
```
60+
61+
Note: If you change the codebase, make sure to restart the MCP server.
62+
3263
### Code Quality & Testing
3364

3465
Run code quality checks and tests using the Makefile:
@@ -60,6 +91,3 @@ Documentation is built using [MkDocs](https://www.mkdocs.org/) with the Material
6091
- Content: `docs/` directory
6192
- Auto-generated API docs via [mkdocstrings](https://mkdocstrings.github.io/)
6293
- Deployed via GitHub Pages (automated via GitHub Actions on push to main branch)
63-
64-
65-

src/deepset_mcp/api/client.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from deepset_mcp.api.pipeline.resource import PipelineResource
1616
from deepset_mcp.api.pipeline_template.resource import PipelineTemplateResource
1717
from deepset_mcp.api.protocols import AsyncClientProtocol
18+
from deepset_mcp.api.search_history.resource import SearchHistoryResource
1819
from deepset_mcp.api.secrets.resource import SecretResource
1920
from deepset_mcp.api.transport import (
2021
AsyncTransport,
@@ -103,6 +104,14 @@ def pipeline_templates(self, workspace: str) -> PipelineTemplateResource:
103104
"""
104105
return PipelineTemplateResource(client=self, workspace=workspace)
105106

107+
def search_history(self, workspace: str) -> SearchHistoryResource:
108+
"""Resource to interact with search history in the specified workspace.
109+
110+
:param workspace: Workspace identifier
111+
:returns: Search history resource instance
112+
"""
113+
return SearchHistoryResource(client=self, workspace=workspace)
114+
106115
def haystack_service(self) -> HaystackServiceResource:
107116
"""Resource to interact with the Haystack service API.
108117

src/deepset_mcp/api/protocols.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from deepset_mcp.api.integrations.protocols import IntegrationResourceProtocol
1616
from deepset_mcp.api.pipeline.protocols import PipelineResourceProtocol
1717
from deepset_mcp.api.pipeline_template.protocols import PipelineTemplateResourceProtocol
18+
from deepset_mcp.api.search_history.protocols import SearchHistoryResourceProtocol
1819
from deepset_mcp.api.secrets.protocols import SecretResourceProtocol
1920
from deepset_mcp.api.user.protocols import UserResourceProtocol
2021
from deepset_mcp.api.workspace.protocols import WorkspaceResourceProtocol
@@ -126,3 +127,7 @@ def workspaces(self) -> "WorkspaceResourceProtocol":
126127
def integrations(self) -> "IntegrationResourceProtocol":
127128
"""Access integrations."""
128129
...
130+
131+
def search_history(self, workspace: str) -> "SearchHistoryResourceProtocol":
132+
"""Access search history in the specified workspace."""
133+
...
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
# SPDX-FileCopyrightText: 2025-present deepset GmbH <info@deepset.ai>
2+
#
3+
# SPDX-License-Identifier: Apache-2.0
4+
5+
"""Search history API module."""
6+
7+
from .models import SearchHistoryEntry
8+
from .resource import SearchHistoryResource
9+
10+
__all__ = ["SearchHistoryEntry", "SearchHistoryResource"]
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# SPDX-FileCopyrightText: 2025-present deepset GmbH <info@deepset.ai>
2+
#
3+
# SPDX-License-Identifier: Apache-2.0
4+
5+
"""Models for search history API."""
6+
7+
from typing import Any
8+
9+
from pydantic import BaseModel, Field, field_validator
10+
11+
12+
class SearchHistoryEntry(BaseModel):
13+
"""A single search history entry from the deepset platform.
14+
15+
Contains query, answers, prompts, feedback, and other metadata.
16+
"""
17+
18+
model_config = {"extra": "allow"}
19+
20+
query: str | None = Field(default=None, description="The search query that was executed")
21+
answer: str | None = Field(default=None, description="The answer returned by the pipeline")
22+
created_at: str | None = Field(default=None, description="When the search was performed")
23+
pipeline_name: str | None = Field(default=None, description="Name of the pipeline used")
24+
feedback: list[dict[str, Any]] | None = Field(default=None, description="User feedback on the search")
25+
26+
@field_validator("feedback", mode="before")
27+
@classmethod
28+
def _feedback_to_list(cls, v: Any) -> list[dict[str, Any]] | None:
29+
if v is None:
30+
return None
31+
if isinstance(v, list):
32+
return v
33+
if isinstance(v, dict):
34+
return [v]
35+
return None
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# SPDX-FileCopyrightText: 2025-present deepset GmbH <info@deepset.ai>
2+
#
3+
# SPDX-License-Identifier: Apache-2.0
4+
5+
"""Protocols for search history resources."""
6+
7+
from typing import Protocol
8+
9+
from deepset_mcp.api.search_history.models import SearchHistoryEntry
10+
from deepset_mcp.api.shared_models import PaginatedResponse
11+
12+
13+
class SearchHistoryResourceProtocol(Protocol):
14+
"""Protocol defining the interface for search history resources."""
15+
16+
async def list(self, limit: int = 10, after: str | None = None) -> PaginatedResponse[SearchHistoryEntry]:
17+
"""List search history entries in the workspace.
18+
19+
:param limit: Maximum number of entries to return per page.
20+
:param after: Cursor to fetch the next page of results.
21+
:returns: Paginated response of search history entries.
22+
"""
23+
...
24+
25+
async def list_pipeline(
26+
self, pipeline_name: str, limit: int = 10, after: str | None = None
27+
) -> PaginatedResponse[SearchHistoryEntry]:
28+
"""List search history entries for a specific pipeline with pagination.
29+
30+
:param pipeline_name: Name of the pipeline.
31+
:param limit: Maximum number of entries to return per page.
32+
:param after: Cursor to fetch the next page of results.
33+
:returns: Paginated response of search history entries (most recent first).
34+
"""
35+
...
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
# SPDX-FileCopyrightText: 2025-present deepset GmbH <info@deepset.ai>
2+
#
3+
# SPDX-License-Identifier: Apache-2.0
4+
5+
"""Resource implementation for search history API."""
6+
7+
from typing import TYPE_CHECKING
8+
from urllib.parse import quote
9+
10+
from deepset_mcp.api.search_history.models import SearchHistoryEntry
11+
from deepset_mcp.api.search_history.protocols import SearchHistoryResourceProtocol
12+
from deepset_mcp.api.shared_models import PaginatedResponse
13+
from deepset_mcp.api.transport import raise_for_status
14+
15+
if TYPE_CHECKING:
16+
from deepset_mcp.api.protocols import AsyncClientProtocol
17+
18+
19+
class SearchHistoryResource(SearchHistoryResourceProtocol):
20+
"""Manages interactions with the deepset search history API."""
21+
22+
def __init__(self, client: "AsyncClientProtocol", workspace: str) -> None:
23+
"""Initialize the search history resource.
24+
25+
:param client: The async REST client.
26+
:param workspace: The workspace to use.
27+
"""
28+
self._client = client
29+
self._workspace = workspace
30+
31+
def _base_path(self) -> str:
32+
return f"v1/workspaces/{quote(self._workspace, safe='')}/search_history"
33+
34+
def _pipeline_path(self, pipeline_name: str) -> str:
35+
return (
36+
f"v1/workspaces/{quote(self._workspace, safe='')}/pipelines/{quote(pipeline_name, safe='')}/search_history"
37+
)
38+
39+
async def list(self, limit: int = 10, after: str | None = None) -> PaginatedResponse[SearchHistoryEntry]:
40+
"""List search history entries in the workspace.
41+
42+
:param limit: Maximum number of entries to return per page.
43+
:param after: Cursor to fetch the next page of results.
44+
:returns: Paginated response of search history entries.
45+
"""
46+
params: dict[str, str | int] = {"limit": limit}
47+
if after is not None:
48+
params["after"] = after
49+
50+
resp = await self._client.request(
51+
endpoint=self._base_path(),
52+
method="GET",
53+
params=params,
54+
timeout=70.0,
55+
)
56+
57+
raise_for_status(resp)
58+
59+
if resp.json is None:
60+
return PaginatedResponse(
61+
data=[],
62+
has_more=False,
63+
total=0,
64+
next_cursor=None,
65+
)
66+
67+
# API may return paginated shape: { "data": [...], "has_more": bool, "total": int }
68+
data = resp.json if isinstance(resp.json, dict) else {"data": resp.json}
69+
items = data.get("data", [])
70+
if not isinstance(items, list):
71+
items = []
72+
73+
return PaginatedResponse[SearchHistoryEntry].create_with_cursor_field(
74+
{
75+
"data": items,
76+
"has_more": data.get("has_more", False),
77+
"total": data.get("total"),
78+
},
79+
"created_at",
80+
)
81+
82+
async def list_pipeline(
83+
self, pipeline_name: str, limit: int = 10, after: str | None = None
84+
) -> PaginatedResponse[SearchHistoryEntry]:
85+
"""List search history entries for a specific pipeline with pagination.
86+
87+
Uses the pipeline search history archive endpoint (full history, most recent first).
88+
89+
:param pipeline_name: Name of the pipeline.
90+
:param limit: Maximum number of entries to return per page.
91+
:param after: Cursor to fetch the next page of results.
92+
:returns: Paginated response of search history entries.
93+
"""
94+
params: dict[str, str | int] = {"limit": limit}
95+
if after is not None:
96+
params["after"] = after
97+
98+
resp = await self._client.request(
99+
endpoint=f"{self._pipeline_path(pipeline_name)}_archive",
100+
method="GET",
101+
params=params,
102+
timeout=70.0,
103+
)
104+
105+
raise_for_status(resp)
106+
107+
if resp.json is None:
108+
return PaginatedResponse(
109+
data=[],
110+
has_more=False,
111+
total=0,
112+
next_cursor=None,
113+
)
114+
115+
data = resp.json if isinstance(resp.json, dict) else {"data": resp.json}
116+
items = data.get("data", [])
117+
if not isinstance(items, list):
118+
items = []
119+
120+
return PaginatedResponse[SearchHistoryEntry].create_with_cursor_field(
121+
{
122+
"data": items,
123+
"has_more": data.get("has_more", False),
124+
"total": data.get("total"),
125+
},
126+
"created_at",
127+
)

src/deepset_mcp/mcp/tool_registry.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,10 @@
4747
list_templates as list_pipeline_templates_tool,
4848
search_templates as search_pipeline_templates_tool,
4949
)
50+
from deepset_mcp.tools.search_history import (
51+
list_pipeline_search_history as list_pipeline_search_history_tool,
52+
list_search_history as list_search_history_tool,
53+
)
5054
from deepset_mcp.tools.secrets import get_secret as get_secret_tool, list_secrets as list_secrets_tool
5155
from deepset_mcp.tools.workspace import (
5256
create_workspace as create_workspace_tool,
@@ -184,6 +188,14 @@ async def search_docs(query: str) -> str:
184188
custom_args={"model": get_initialized_model()},
185189
),
186190
),
191+
"list_search_history": (
192+
list_search_history_tool,
193+
ToolConfig(needs_client=True, needs_workspace=True, memory_type=MemoryType.EXPLORABLE),
194+
),
195+
"list_pipeline_search_history": (
196+
list_pipeline_search_history_tool,
197+
ToolConfig(needs_client=True, needs_workspace=True, memory_type=MemoryType.EXPLORABLE),
198+
),
187199
"list_custom_component_installations": (
188200
list_custom_component_installations_tool,
189201
ToolConfig(needs_client=True, needs_workspace=True, memory_type=MemoryType.EXPLORABLE),

src/deepset_mcp/tools/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
validate_pipeline,
2727
)
2828
from .pipeline_template import get_template, list_templates, search_templates
29+
from .search_history import list_pipeline_search_history, list_search_history
2930
from .secrets import get_secret, list_secrets
3031
from .workspace import get_workspace, list_workspaces
3132

@@ -59,6 +60,8 @@
5960
"list_templates",
6061
"get_template",
6162
"search_templates",
63+
"list_search_history",
64+
"list_pipeline_search_history",
6265
"get_secret",
6366
"list_secrets",
6467
"list_workspaces",

0 commit comments

Comments
 (0)