Skip to content

Commit afa3641

Browse files
dougborgclaude
andcommitted
feat(mcp): extend rebuild_cache to cover catalog entity types
`force_resync` already handles every key in `ENTITY_SPECS` (16 total) — but the `CacheEntityType` literal on `RebuildCacheRequest` restricted callers to the 5 transactional ones. Catalog entities (variants, products, materials, services, customers, suppliers, locations, tax rates, operators, factories, additional costs) couldn't be rebuilt through the tool even though the underlying capability existed. This came up while verifying the #669 location regression — "delete the cache file" was the only documented escape hatch for catalog drift. Extend the literal to cover all 16 entity types. Also pin the literal-vs-`ENTITY_SPECS` consistency with a test that loops over every entity in `ENTITY_SPECS` and asserts the request validator accepts it — adding a new entity to the typed cache without extending the literal would otherwise silently exclude it from the tool. Smoke-test `location` end-to-end (phantom-cleanup happy path) to prove the catalog tier actually works through the tool, not just through `force_resync` directly. Update the tool docstring, the request-field description, and the help-resource entries so callers see the expanded set in tool introspection and host-rendered docs. Refs #659 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 03a714c commit afa3641

3 files changed

Lines changed: 130 additions & 11 deletions

File tree

katana_mcp_server/src/katana_mcp/resources/help.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@
8585
- **list_additional_costs** - List or fuzzy-search the additional-cost catalog (freight, duties, handling). Use for `additional_cost_id` on `modify_purchase_order`. Supports `query`, `limit`, `format`.
8686
8787
### Cache Administration
88-
- **rebuild_cache** - Force-rebuild the local typed cache for one or more transactional entity types (PO, SO, MO, stock adjustment, stock transfer). Truncates the cache table(s), clears the sync watermark, and re-fetches from Katana. Use when the cache has phantom rows (entities present locally but missing from Katana). Destructive; preview/apply.
88+
- **rebuild_cache** - Force-rebuild the local typed cache for one or more cached entity types. Covers transactional entities (PO, SO, MO, stock adjustment, stock transfer) and catalog entities (variant, product, material, service, customer, supplier, location, tax_rate, operator, factory, additional_cost). Truncates the cache table(s), clears the sync watermark, and re-fetches from Katana. Use when the cache has phantom rows (entities present locally but missing from Katana). Destructive; preview/apply.
8989
9090
## Safety Pattern
9191
@@ -1820,7 +1820,7 @@
18201820
## Cache Administration Tools
18211821
18221822
### rebuild_cache
1823-
Force-rebuild the local typed cache for one or more transactional entity types.
1823+
Force-rebuild the local typed cache for one or more cached entity types.
18241824
The steady-state sync path upserts via `session.merge` and never deletes — soft-
18251825
deletes from Katana are folded in correctly because the tombstone surfaces in
18261826
the next `updated_at_min` delta, but rows that left Katana without a tombstone
@@ -1837,8 +1837,11 @@
18371837
18381838
**Parameters:**
18391839
- `entity_types` (required, min length 1): list of entity types to rebuild.
1840-
Allowed values: `purchase_order`, `sales_order`, `manufacturing_order`,
1841-
`stock_adjustment`, `stock_transfer`.
1840+
Allowed values cover transactional entities (`purchase_order`, `sales_order`,
1841+
`manufacturing_order`, `stock_adjustment`, `stock_transfer`) and catalog
1842+
entities (`variant`, `product`, `material`, `service`, `customer`,
1843+
`supplier`, `location`, `tax_rate`, `operator`, `factory`,
1844+
`additional_cost`).
18421845
- `preview` (optional, default true): true = report current row counts and
18431846
last-synced timestamps without modifying anything; false = perform the
18441847
destructive rebuild.

katana_mcp_server/src/katana_mcp/tools/foundation/cache_admin.py

Lines changed: 40 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,9 @@
33
Operational utilities for the typed cache (``katana_mcp.typed_cache``).
44
55
Currently provides ``rebuild_cache``: a destructive force-resync that
6-
truncates the cache tables for a set of transactional entity types,
7-
clears their ``SyncState`` watermarks, and re-fetches the live state
8-
from Katana — atomically under the entity's sync lock so concurrent
6+
truncates the cache tables for a set of cached entity types, clears
7+
their ``SyncState`` watermarks, and re-fetches the live state from
8+
Katana — atomically under the entity's sync lock so concurrent
99
``list_*`` tools never see the empty intermediate state.
1010
1111
Why this exists: the steady-state sync path upserts via
@@ -17,6 +17,12 @@
1717
``list_*`` tools filter ``deleted_at IS NULL``, but phantom rows that
1818
never received a soft-delete bump still leak. Rebuild is the manual
1919
escape hatch.
20+
21+
Coverage: every key in ``katana_mcp.typed_cache.ENTITY_SPECS`` is
22+
addressable — both the transactional entities (PO, SO, MO + recipe
23+
rows, stock adjustments, stock transfers) and the catalog entities
24+
(variants, products, materials, services, customers, suppliers,
25+
locations, tax rates, operators, factories, additional costs).
2026
"""
2127

2228
from __future__ import annotations
@@ -49,11 +55,26 @@
4955
# pyright ``Literal`` mismatch until added below, catching the drift at
5056
# review time rather than runtime).
5157
CacheEntityType = Literal[
58+
# Transactional entities — parent + nested/related child rows.
5259
"purchase_order",
5360
"sales_order",
5461
"manufacturing_order",
5562
"stock_adjustment",
5663
"stock_transfer",
64+
# Catalog entities — flat tables, no inline child rows. ``variant``
65+
# carries denormalized parent-archive state; the rest are simple
66+
# name/id lookups against the corresponding Katana endpoints.
67+
"variant",
68+
"product",
69+
"material",
70+
"service",
71+
"customer",
72+
"supplier",
73+
"location",
74+
"tax_rate",
75+
"operator",
76+
"factory",
77+
"additional_cost",
5778
]
5879

5980

@@ -73,7 +94,11 @@ class RebuildCacheRequest(BaseModel):
7394
description=(
7495
"Entity types to rebuild. Each entry truncates the cache "
7596
"table(s) for that entity, clears its sync watermark, and "
76-
"re-fetches the live state from Katana."
97+
"re-fetches the live state from Katana. Covers transactional "
98+
"entities (purchase_order, sales_order, manufacturing_order, "
99+
"stock_adjustment, stock_transfer) and catalog entities "
100+
"(variant, product, material, service, customer, supplier, "
101+
"location, tax_rate, operator, factory, additional_cost)."
77102
),
78103
)
79104
preview: bool = Field(
@@ -271,7 +296,7 @@ def _format_markdown(response: RebuildCacheResponse) -> str:
271296
async def rebuild_cache(
272297
request: Annotated[RebuildCacheRequest, Unpack()], context: Context
273298
) -> ToolResult:
274-
"""Force-rebuild the local typed cache for one or more transactional entity types.
299+
"""Force-rebuild the local typed cache for one or more cached entity types.
275300
276301
Use this when the local cache has drifted from Katana — the most
277302
common symptom is "phantom" rows (entities present in the cache
@@ -290,8 +315,16 @@ async def rebuild_cache(
290315
locks, so concurrent ``list_*`` tools block until the cache is
291316
repopulated and never see the empty intermediate state.
292317
293-
**Supported entity types:** ``purchase_order``, ``sales_order``,
294-
``manufacturing_order``, ``stock_adjustment``, ``stock_transfer``.
318+
**Supported entity types:**
319+
320+
- Transactional: ``purchase_order``, ``sales_order``,
321+
``manufacturing_order``, ``stock_adjustment``, ``stock_transfer``
322+
(each rebuilds the parent table plus its child/related-spec
323+
tables, e.g. PO + PO rows, MO + MO recipe rows).
324+
- Catalog: ``variant``, ``product``, ``material``, ``service``,
325+
``customer``, ``supplier``, ``location``, ``tax_rate``,
326+
``operator``, ``factory``, ``additional_cost`` (flat tables,
327+
no inline child rows).
295328
296329
**Two-step flow:**
297330
- ``preview=true`` (default) — reports current row counts and last-

katana_mcp_server/tests/tools/test_cache_admin.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -563,6 +563,89 @@ async def reader():
563563
]
564564

565565

566+
# ============================================================================
567+
# Catalog entity coverage — `rebuild_cache` accepts all 16 keys in
568+
# `ENTITY_SPECS`, not just the 5 transactional ones it shipped with.
569+
# Smoke-test one catalog entity end-to-end (location — the headline #669
570+
# regression source) plus a literal-shape test that pins the full
571+
# accepted set against `ENTITY_SPECS` so adding a new entity to the
572+
# typed cache surfaces the missing literal at review time.
573+
# ============================================================================
574+
575+
576+
class TestCatalogEntityRebuild:
577+
@pytest.mark.asyncio
578+
async def test_rebuild_location_clears_phantom_and_repulls(
579+
self, typed_cache_engine
580+
):
581+
"""End-to-end smoke test for the catalog tier of `rebuild_cache`.
582+
583+
Exercises one catalog entity (location) through the full
584+
`force_resync` path: seed two locations (one of which the live
585+
API will omit), patch the API to return only the live one,
586+
confirm the phantom is gone after rebuild and that the live row
587+
survives. Same shape as the headline transactional test
588+
`test_phantom_purchase_order_is_removed_after_rebuild`, just
589+
against a flat catalog table.
590+
"""
591+
from katana_public_api_client.models import Location as AttrsLocation
592+
from katana_public_api_client.models_pydantic._generated import (
593+
CachedLocation,
594+
)
595+
596+
# Seed: one row Katana still has, one phantom Katana doesn't.
597+
async with typed_cache_engine.session() as session:
598+
session.add(CachedLocation(id=1, name="Main Warehouse"))
599+
session.add(CachedLocation(id=999, name="Phantom Warehouse"))
600+
await session.commit()
601+
602+
context = _build_context(typed_cache_engine)
603+
live_location = AttrsLocation.from_dict({"id": 1, "name": "Main Warehouse"})
604+
live_response = MagicMock()
605+
live_response.status_code = 200
606+
live_response.parsed = MagicMock(data=[live_location])
607+
608+
with patch(
609+
"katana_mcp.typed_cache.sync.get_all_locations.asyncio_detailed",
610+
new=AsyncMock(return_value=live_response),
611+
):
612+
response = await _rebuild_cache_impl(
613+
RebuildCacheRequest(entity_types=["location"], preview=False),
614+
context,
615+
)
616+
617+
assert response.is_preview is False
618+
result = response.results[0]
619+
assert result.entity_type == "location"
620+
assert result.parent_rows_before == 2
621+
assert result.parent_rows_after == 1
622+
# Catalog entities have no related-spec children, so the only
623+
# cleared key is the parent's own watermark.
624+
assert result.sync_state_keys_cleared == ["location"]
625+
626+
async with typed_cache_engine.session() as session:
627+
remaining = (await session.exec(select(CachedLocation))).all()
628+
ids = {loc.id for loc in remaining}
629+
assert ids == {1}, f"Phantom location 999 should be gone, got {ids}"
630+
631+
def test_request_accepts_all_entity_specs_keys(self):
632+
"""The `CacheEntityType` literal must stay in lock-step with
633+
`ENTITY_SPECS`. Adding a new entity to the typed cache without
634+
also extending the `Literal` would silently exclude it from the
635+
rebuild_cache tool — tested explicitly so the gap can't ship.
636+
"""
637+
for entity_key in ENTITY_SPECS:
638+
# `model_validate` exercises the Literal check at runtime;
639+
# success means the key is in the accepted set.
640+
req = RebuildCacheRequest.model_validate(
641+
{"entity_types": [entity_key], "preview": True}
642+
)
643+
assert req.entity_types == [entity_key], (
644+
f"{entity_key!r} accepted by ENTITY_SPECS but rejected by "
645+
f"CacheEntityType — extend the Literal in cache_admin.py."
646+
)
647+
648+
566649
# ============================================================================
567650
# Request validation — invalid entity types fail at the request boundary
568651
# ============================================================================

0 commit comments

Comments
 (0)