Skip to content

Commit a7c8f2f

Browse files
authored
fix(mcp): hard-cut search cursor placeholders (#1732)
Keep MCP search tools honest until pagination exists, and align capability metadata with client-runtime features rather than collection index state. Made-with: Cursor
1 parent 00ae644 commit a7c8f2f

17 files changed

Lines changed: 165 additions & 312 deletions

Makefile

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -185,7 +185,7 @@ serve-web:
185185
##################################################
186186

187187
# Code quality checks
188-
.PHONY: format lint static-check
188+
.PHONY: format lint static-check add-license
189189
format:
190190
uvx ruff check --fix ./aperag ./tests
191191
uvx ruff format ./aperag ./tests
@@ -197,6 +197,9 @@ lint:
197197
static-check:
198198
uvx mypy ./aperag
199199

200+
add-license:
201+
@echo "License headers are maintained in source files."
202+
200203
# Testing suite
201204
.PHONY: test-all test-unit test-integration test-e2e test-e2e-perf \
202205
test-http-bootstrap test-http-smoke test-http-full \

aperag/cache/invalidation.py

Lines changed: 7 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -50,20 +50,13 @@ async def invalidate_document(
5050
) -> None:
5151
"""Purge cached entries that pin to a specific document.
5252
53-
Removes ``d10:outline:{document_id}:*``,
54-
``d10:section:{document_id}:*`` and ``d10:content:{document_id}:*``
55-
from both L1 and L2.
56-
57-
NOTE: ``d10:chunk:*`` is keyed by ``chunk_id`` (not ``document_id``),
58-
so a per-document invalidation does **not** purge chunk entries.
59-
Callers that delete a document and need chunk-level invalidation
60-
must walk the document's chunk list and invalidate each chunk_id
61-
separately. This is intentional — keeps the chunk namespace
62-
indexing-immutable per §E.6 and avoids requiring the cache to know
63-
chunk-to-document mapping.
53+
Removes the read primitive namespaces from both L1 and L2. The
54+
cache API currently supports namespace-level deletion rather than
55+
prefix deletion, so this intentionally over-purges to avoid stale
56+
reads after document removal or index rebuild.
6457
"""
6558

66-
namespaces = ("outline", "section", "content")
59+
namespaces = ("outline", "section", "content", "chunk")
6760
for ns in namespaces:
6861
# The current cache stores a flat key per ``(namespace,
6962
# document_id, parse_version, ...)`` so we cannot pinpoint a
@@ -89,12 +82,10 @@ async def invalidate_collection(
8982
Same conservative-over-purge semantics as
9083
:func:`invalidate_document` — the cache layer doesn't know which
9184
documents belong to which collection without an external mapping,
92-
so on D11+ write-tool calls we simply purge the parse-version-bound
93-
namespaces. ``d10:chunk:*`` again is not affected (chunk_id is
94-
indexing-immutable).
85+
so on D11+ write-tool calls we simply purge every read namespace.
9586
"""
9687

97-
namespaces = ("outline", "section", "content")
88+
namespaces = ("outline", "section", "content", "chunk")
9889
for ns in namespaces:
9990
await cache.l1.delete_namespace(ns)
10091
await cache.l2.delete_namespace(ns)

aperag/cache/read_primitive_cache.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -138,10 +138,11 @@ async def get_or_compute_outline(
138138
*,
139139
document_id: str,
140140
parse_version: str,
141+
max_depth: int = 6,
141142
compute: Callable[[], Awaitable[ModelT]],
142143
model_cls: type[ModelT],
143144
) -> ModelT:
144-
key = _build_key(NAMESPACE_OUTLINE, document_id, parse_version)
145+
key = _build_key(NAMESPACE_OUTLINE, document_id, parse_version, str(int(max_depth)))
145146
return await self._get_or_compute(key=key, compute=compute, model_cls=model_cls)
146147

147148
async def get_or_compute_section(
@@ -177,13 +178,13 @@ async def get_or_compute_content(
177178
async def get_or_compute_chunk(
178179
self,
179180
*,
181+
collection_id: str,
182+
document_id: str,
180183
chunk_id: str,
181184
compute: Callable[[], Awaitable[ModelT]],
182185
model_cls: type[ModelT],
183186
) -> ModelT:
184-
# §E.6: chunk_id is indexing-layer-immutable, so there is no
185-
# parse_version dimension on this namespace.
186-
key = _build_key(NAMESPACE_CHUNK, chunk_id)
187+
key = _build_key(NAMESPACE_CHUNK, collection_id, document_id, chunk_id)
187188
return await self._get_or_compute(key=key, compute=compute, model_cls=model_cls)
188189

189190
# ------------------------------------------------------------------

aperag/mcp/capabilities.py

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,10 @@
2626
``list_tools`` metadata for external Agents (Claude Code / Codex /
2727
Cursor).
2828
29-
* :data:`KNOWN_CAPABILITIES` — the closed set of capability keys that
30-
D10 currently uses (``vision``, ``long_context``, ``graph_index``,
31-
``fulltext_index``, ``web_access``). Adding a new capability key
32-
requires a ``[D10 spec amendment]`` thread per §G hard gate.
29+
* :data:`KNOWN_CAPABILITIES` — the closed set of client/runtime
30+
capability keys that D10 currently uses (``vision``,
31+
``long_context``, ``web_access``). Collection index availability is
32+
collection state, not a client capability.
3333
3434
The actual registry of tool name → annotation lives in
3535
:mod:`aperag.mcp.tools._annotations`. Server-side filtering (Option B)
@@ -50,8 +50,6 @@
5050
{
5151
"vision", # client can render / reason over images
5252
"long_context", # client tolerates large content payloads
53-
"graph_index", # collection has a graph index built
54-
"fulltext_index", # collection has a fulltext index built
5553
"web_access", # caller may reach the public internet
5654
}
5755
)
@@ -81,8 +79,6 @@ class ToolAnnotation(BaseModel):
8179
"capabilities": {
8280
"vision": False,
8381
"long_context": False,
84-
"graph_index": True,
85-
"fulltext_index": True,
8682
"web_access": True,
8783
},
8884
"deprecated": False,

aperag/mcp/server.py

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -57,8 +57,9 @@
5757
# Initialize FastMCP server
5858
mcp_server = FastMCP("ApeRAG")
5959

60-
# Base URL for internal API calls
61-
API_BASE_URL = "http://localhost:8000"
60+
# Base URL for internal API calls. Deployments can point the MCP server
61+
# at a colocated API service without changing the public tool surface.
62+
API_BASE_URL = os.getenv("APERAG_API_BASE_URL", "http://localhost:8000").rstrip("/")
6263

6364

6465
# === D10.c read primitives ===
@@ -298,7 +299,15 @@ async def read_document_chunk(
298299
# in the same cutover.
299300

300301

301-
@mcp_server.tool
302+
@mcp_server.tool(
303+
annotations=_register_tool_annotation(
304+
"web_read",
305+
ToolAnnotation(
306+
requires=("web_access",),
307+
capabilities={"long_context": False, "web_access": True},
308+
),
309+
),
310+
)
302311
async def web_read(
303312
url_list: list[str],
304313
timeout: int = 30,

aperag/mcp/tools/read_document_chunk.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -84,9 +84,8 @@ async def read_document_chunk(
8484
parse_version = resolve_parse_version(document, collection)
8585

8686
# 5. Fetch authoritative chunk — D10.g cache-wrapped.
87-
# §E.6: chunk_id is indexing-immutable, so the cache key is
88-
# ``(chunk_id,)`` only — no parse_version weighting. Tenancy/auth
89-
# above are NEVER skipped (§E.7 hard lock).
87+
# The key includes collection and document scope because chunk ids
88+
# are not guaranteed globally unique across all tenants.
9089
cache = await get_read_primitive_cache()
9190

9291
async def _compute() -> DocumentChunk:
@@ -117,6 +116,8 @@ async def _compute() -> DocumentChunk:
117116
)
118117

119118
return await cache.get_or_compute_chunk(
119+
collection_id=collection_id,
120+
document_id=document_id,
120121
chunk_id=chunk_id,
121122
compute=_compute,
122123
model_cls=DocumentChunk,

aperag/mcp/tools/read_document_outline.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ async def _compute() -> DocumentOutline:
102102
return await cache.get_or_compute_outline(
103103
document_id=document.id,
104104
parse_version=parse_version,
105+
max_depth=max_depth,
105106
compute=_compute,
106107
model_cls=DocumentOutline,
107108
)

aperag/mcp/tools/search_fulltext.py

Lines changed: 4 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414

15-
"""Phase 9 D10.d (#96) — fulltext_search split MCP tool (§B.3).
15+
"""Full-text search MCP tool.
1616
1717
The discrete keyword / full-text search primitive per
1818
``docs/modularization/d10-design-pack.md`` §B.3 (Lock #5 split). The
@@ -25,11 +25,8 @@
2525
``section_path`` / ``heading_anchor`` so callers can chain into the
2626
read primitives.
2727
28-
The ``cursor`` parameter is a placeholder: the signature lands now
29-
so external clients see the canonical shape, but the body raises
30-
``NotImplementedError`` on any non-empty value until real search
31-
pagination ships. ``None`` and ``""`` both preserve single-page
32-
``top_k`` behavior.
28+
Search pagination is not part of the public MCP contract yet, so this
29+
tool intentionally exposes only single-page ``top_k`` retrieval.
3330
"""
3431

3532
from __future__ import annotations
@@ -52,9 +49,7 @@
5249
"fulltext_search",
5350
ToolAnnotation(
5451
requires=("collection_access",),
55-
# fulltext_search needs the inverted index — explicit-not-silent
56-
# per §D.3 if the collection has no fulltext index.
57-
capabilities={"long_context": False, "fulltext_index": True},
52+
capabilities={"long_context": False},
5853
),
5954
),
6055
)
@@ -65,7 +60,6 @@ async def fulltext_search(
6560
top_k: int = 5,
6661
keywords: list[str] | None = None,
6762
rerank: bool = True,
68-
cursor: str | None = None,
6963
) -> Dict[str, Any]:
7064
"""Full-text keyword search within a collection (§B.3).
7165
@@ -100,21 +94,12 @@ async def fulltext_search(
10094
auto-extracted keywords from the query.
10195
rerank: Whether to apply reranker on returned candidates
10296
(default: True).
103-
cursor: Pagination cursor placeholder (§B.3 / amendment
104-
msg=b9b7072a Drift #4 (c)). ``None`` and ``""`` return
105-
first page; any non-empty value raises
106-
``NotImplementedError`` with a clear "not implemented"
107-
message until real search pagination ships.
10897
10998
Returns:
11099
Search results with ``items`` carrying ``recall_type =
111100
"fulltext_search"``. Highlight snippets / matched terms are
112101
surfaced via ``items[*].metadata``.
113102
"""
114-
if cursor:
115-
raise NotImplementedError(
116-
"search pagination is not yet implemented (tool=fulltext_search, reason=search_not_paginated)"
117-
)
118103
try:
119104
api_key = get_api_key()
120105

aperag/mcp/tools/search_graph.py

Lines changed: 4 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414

15-
"""Phase 9 D10.d (#96) — graph_search split MCP tool (§B.2).
15+
"""Graph search MCP tool.
1616
1717
The discrete knowledge-graph search primitive per
1818
``docs/modularization/d10-design-pack.md`` §B.2 (Lock #5 split). The
@@ -25,11 +25,8 @@
2525
``section_path`` / ``heading_anchor`` so callers can chain into the
2626
read primitives.
2727
28-
The ``cursor`` parameter is a placeholder: the signature lands now
29-
so external clients see the canonical shape, but the body raises
30-
``NotImplementedError`` on any non-empty value until real search
31-
pagination ships. ``None`` and ``""`` both preserve single-page
32-
``top_k`` behavior.
28+
Search pagination is not part of the public MCP contract yet, so this
29+
tool intentionally exposes only single-page ``top_k`` retrieval.
3330
"""
3431

3532
from __future__ import annotations
@@ -52,9 +49,7 @@
5249
"graph_search",
5350
ToolAnnotation(
5451
requires=("collection_access",),
55-
# graph_search returns nothing useful unless the collection
56-
# has a graph index built — explicit-not-silent per §D.3.
57-
capabilities={"long_context": False, "graph_index": True},
52+
capabilities={"long_context": False},
5853
),
5954
),
6055
)
@@ -63,7 +58,6 @@ async def graph_search(
6358
query: str,
6459
*,
6560
top_k: int = 5,
66-
cursor: str | None = None,
6761
) -> Dict[str, Any]:
6862
"""Knowledge-graph search within a collection (§B.2).
6963
@@ -95,21 +89,12 @@ async def graph_search(
9589
collection_id: The ID of the collection to search.
9690
query: The natural-language search query.
9791
top_k: Maximum number of results to return (default: 5).
98-
cursor: Pagination cursor placeholder (§B.2 / amendment
99-
msg=b9b7072a Drift #4 (c)). ``None`` and ``""`` return
100-
first page; any non-empty value raises
101-
``NotImplementedError`` with a clear "not implemented"
102-
message until real search pagination ships.
10392
10493
Returns:
10594
Search results with ``items`` carrying ``recall_type =
10695
"graph_search"``. Graph-specific fields (entity / relation / path)
10796
are surfaced via ``items[*].metadata``.
10897
"""
109-
if cursor:
110-
raise NotImplementedError(
111-
"search pagination is not yet implemented (tool=graph_search, reason=search_not_paginated)"
112-
)
11398
try:
11499
api_key = get_api_key()
115100

aperag/mcp/tools/search_vector.py

Lines changed: 3 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414

15-
"""Phase 9 D10.d (#96) — vector_search split MCP tool (§B.1).
15+
"""Vector search MCP tool.
1616
1717
The discrete vector-similarity search primitive per
1818
``docs/modularization/d10-design-pack.md`` §B.1 (Lock #5 split). The
@@ -27,18 +27,8 @@
2727
caller can navigate from a search hit straight into the read
2828
primitives.
2929
30-
The ``cursor`` parameter is a placeholder per the same amendment
31-
thread (Drift #4 resolution (c)): the signature is published now so
32-
external MCP clients see the canonical shape, but the body explicitly
33-
raises ``NotImplementedError("search pagination is not yet
34-
implemented")`` on any **non-empty** value. The architect sign-off
35-
(msg=ebfcdabe) plus Weston's blocker review (msg=177a1dd8) explicitly
36-
forbid reusing the canonical ``CursorError("cursor_invalid", ...)``
37-
malformed-wire semantic for this "feature not implemented" case —
38-
that would camouflage missing capability as a client-side cursor bug.
39-
``cursor=None`` and ``cursor=""`` both keep the existing single-page
40-
``top_k`` behavior (Weston msg=177a1dd8 lock); only truthy /
41-
non-empty cursor values trigger the loud-fail.
30+
Search pagination is not part of the public MCP contract yet, so this
31+
tool intentionally exposes only single-page ``top_k`` retrieval.
4232
"""
4333

4434
from __future__ import annotations
@@ -72,7 +62,6 @@ async def vector_search(
7262
top_k: int = 5,
7363
similarity_threshold: float | None = None,
7464
rerank: bool = True,
75-
cursor: str | None = None,
7665
) -> Dict[str, Any]:
7766
"""Vector similarity search within a collection (§B.1).
7867
@@ -107,26 +96,11 @@ async def vector_search(
10796
similarity_threshold: Minimum similarity score [0, 1]; ``None``
10897
uses the collection's default threshold.
10998
rerank: Whether to apply reranker on returned candidates (default: True).
110-
cursor: Pagination cursor placeholder (§B.1). ``None`` and
111-
``""`` both return the first page (current behavior); any
112-
non-empty value raises ``NotImplementedError`` with a
113-
``"search pagination is not yet implemented"`` message per
114-
the ``[D10 spec amendment]`` (msg=b9b7072a + sign-off
115-
msg=ebfcdabe + Weston msg=177a1dd8). Real search
116-
pagination requires a backend capability that is not yet
117-
available and will land in a dedicated D11+ upgrade.
11899
119100
Returns:
120101
Search results with ``items`` ranked by vector similarity. Each
121102
item carries ``recall_type = "vector_search"``.
122103
"""
123-
if cursor:
124-
# ``cursor`` is truthy iff non-None and non-empty (Weston
125-
# msg=177a1dd8 lock: ``None`` and ``""`` both preserve
126-
# single-page ``top_k`` behavior).
127-
raise NotImplementedError(
128-
"search pagination is not yet implemented (tool=vector_search, reason=search_not_paginated)"
129-
)
130104
try:
131105
api_key = get_api_key()
132106

0 commit comments

Comments
 (0)