Skip to content

Commit 1e97f6d

Browse files
committed
agentgrep(feat[mcp]): Add a human filter to the MCP search tool
why: The human: distinction was queryable from the CLI but not over MCP — the search tool had no way to keep user-typed prompts or isolate tool output. what: - Add an optional human ("true"|"false") parameter to the MCP search tool and SearchRequestModel; "true" keeps user-typed turns, "false" keeps tool/agent output, omitted keeps both. - Thread it through the page cursor so paginated human-filtered searches stay consistent.
1 parent 02e2608 commit 1e97f6d

4 files changed

Lines changed: 86 additions & 0 deletions

File tree

src/agentgrep/mcp/models.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,7 @@ class SearchRequestModel(AgentGrepModel):
227227
cwd: str | None = None
228228
repo: str | None = None
229229
branch: str | None = None
230+
human: t.Literal["true", "false"] | None = None
230231

231232

232233
class SearchToolResponse(AgentGrepModel):

src/agentgrep/mcp/refs.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ class _SearchCursorPayload(t.TypedDict):
4545
cwd: str | None
4646
repo: str | None
4747
branch: str | None
48+
human: str | None
4849

4950

5051
class _FindCursorPayload(t.TypedDict):
@@ -79,6 +80,7 @@ class SearchCursor:
7980
cwd: str | None = None
8081
repo: str | None = None
8182
branch: str | None = None
83+
human: str | None = None
8284

8385

8486
@dataclasses.dataclass(frozen=True, slots=True)
@@ -245,6 +247,7 @@ def make_search_cursor(
245247
cwd: str | None = None,
246248
repo: str | None = None,
247249
branch: str | None = None,
250+
human: str | None = None,
248251
) -> str:
249252
"""Build an opaque cursor for the next search page."""
250253
return _encode_token(
@@ -263,6 +266,7 @@ def make_search_cursor(
263266
cwd=cwd,
264267
repo=repo,
265268
branch=branch,
269+
human=human,
266270
),
267271
),
268272
)
@@ -313,6 +317,8 @@ def parse_search_cursor(cursor: str) -> SearchCursor:
313317
if branch is not None and not isinstance(branch, str):
314318
msg = "cursor branch must be a string or null"
315319
raise McpTokenError(msg)
320+
human_raw = payload.get("human")
321+
human = human_raw if human_raw in ("true", "false") else None
316322
return SearchCursor(
317323
offset=offset,
318324
terms=t.cast("list[str]", terms),
@@ -323,6 +329,7 @@ def parse_search_cursor(cursor: str) -> SearchCursor:
323329
cwd=cwd,
324330
repo=repo,
325331
branch=branch,
332+
human=t.cast("str | None", human),
326333
)
327334

328335

src/agentgrep/mcp/tools/search_tools.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ def _request_from_cursor(request: SearchRequestModel) -> tuple[SearchRequestMode
8686
cwd=cursor.cwd,
8787
repo=cursor.repo,
8888
branch=cursor.branch,
89+
human=t.cast("t.Literal['true', 'false'] | None", cursor.human),
8990
),
9091
cursor.offset,
9192
)
@@ -193,6 +194,16 @@ async def _search_async(
193194
records.append(t.cast("SearchRecordLike", event.record))
194195
elif isinstance(event, ag_events.SearchFinished):
195196
matched = max(matched, event.match_count)
197+
if effective_request.human is not None:
198+
# Adapters tag tool/assistant output with human_typed=False; a missing
199+
# tag means a user-typed turn (the same rule the ``human:`` query field uses).
200+
want_human = effective_request.human == "true"
201+
records = [
202+
record
203+
for record in records
204+
if ((getattr(record, "metadata", None) or {}).get("human_typed", True) is not False)
205+
== want_human
206+
]
196207
# The inline execution driver emits records per source, not in final
197208
# result order; restore the newest-first contract the list-returning
198209
# search path guarantees before building the response.
@@ -214,6 +225,7 @@ async def _search_async(
214225
cwd=effective_request.cwd,
215226
repo=effective_request.repo,
216227
branch=effective_request.branch,
228+
human=effective_request.human,
217229
)
218230
if has_more
219231
else None
@@ -331,6 +343,16 @@ async def search_tool(
331343
description="Only return records whose recorded git branch matches this name.",
332344
),
333345
] = None,
346+
human: t.Annotated[
347+
t.Literal["true", "false"] | None,
348+
Field(
349+
default=None,
350+
description=(
351+
"Filter by who authored the turn: 'true' keeps user-typed prompts, "
352+
"'false' keeps tool/assistant output. Omit to keep both."
353+
),
354+
),
355+
] = None,
334356
) -> SearchToolResponse:
335357
request = SearchRequestModel(
336358
terms=terms or [],
@@ -342,6 +364,7 @@ async def search_tool(
342364
cwd=cwd,
343365
repo=repo,
344366
branch=branch,
367+
human=human,
345368
)
346369
return await _search_async(request, runtime=runtime)
347370

tests/test_agentgrep_mcp.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -590,6 +590,61 @@ async def test_mcp_search_rejects_invalid_query() -> None:
590590
assert "agent" in error_message
591591

592592

593+
async def test_mcp_search_tool_human_filter(
594+
tmp_path: pathlib.Path,
595+
monkeypatch: pytest.MonkeyPatch,
596+
) -> None:
597+
"""The MCP search ``human`` filter keeps user-typed prompts vs tool/agent output."""
598+
agentgrep_mcp = load_agentgrep_mcp_module()
599+
home = tmp_path / "home"
600+
monkeypatch.setenv("HOME", str(home))
601+
602+
session_path = home / ".codex" / "sessions" / "2026" / "01" / "01" / "rollout.jsonl"
603+
write_jsonl(
604+
session_path,
605+
[
606+
{
607+
"timestamp": "2026-01-01T00:00:00Z",
608+
"type": "session_meta",
609+
"payload": {"id": "session-h", "model_provider": "openai"},
610+
},
611+
{
612+
"timestamp": "2026-01-01T00:00:01Z",
613+
"type": "response_item",
614+
"payload": {
615+
"type": "message",
616+
"role": "user",
617+
"content": [{"type": "input_text", "text": "deploy the widget service"}],
618+
},
619+
},
620+
{
621+
"timestamp": "2026-01-01T00:00:02Z",
622+
"type": "response_item",
623+
"payload": {
624+
"type": "function_call_output",
625+
"output": "widget service: installed 11 packages",
626+
},
627+
},
628+
],
629+
)
630+
631+
async with Client(agentgrep_mcp.build_mcp_server()) as client:
632+
kept = await client.call_tool(
633+
"search",
634+
{"terms": ["widget"], "agent": "codex", "scope": "all", "human": "true", "limit": 5},
635+
)
636+
dropped = await client.call_tool(
637+
"search",
638+
{"terms": ["widget"], "agent": "codex", "scope": "all", "human": "false", "limit": 5},
639+
)
640+
641+
human_data = t.cast("SearchToolDataLike", kept.data)
642+
tool_data = t.cast("SearchToolDataLike", dropped.data)
643+
# human:true keeps the typed prompt; human:false excludes it.
644+
assert any("deploy the widget service" in r.text for r in human_data.results)
645+
assert all("deploy the widget service" not in r.text for r in tool_data.results)
646+
647+
593648
async def test_mcp_search_tool_sorts_records_across_sources(
594649
tmp_path: pathlib.Path,
595650
monkeypatch: pytest.MonkeyPatch,

0 commit comments

Comments
 (0)