Skip to content

Commit 8645d9d

Browse files
Doug Borgclaude
andcommitted
feat(mcp): migrate to StockTrim architecture with unified item creation
Implements Phase 1 of StockTrim architecture migration, establishing production-proven patterns from the sister project. **Architecture Changes:** - Convert ServerContext to @DataClass pattern - Add services layer with get_services() dependency injection helper - Organize tools into foundation/ and workflows/ two-tier structure - Add ItemType enum matching Katana API discriminator **New Services Layer:** - services/dependencies.py: Services dataclass + get_services() helper - Provides clean DI pattern for accessing KatanaClient - Documents which helpers exist vs need generated API **Tool Organization:** - foundation/items.py: Unified item management (search + create) - foundation/inventory.py: Stock operations (migrated from tools/) - workflows/: Empty for now (Phase 3 implementation) **New Unified Item Interface:** - create_item: Single tool for products, materials, and services - Type-aware routing based on ItemType discriminator - Simplified interface with common fields - Handles API differences (UNSET conversion, variant models) - search_items: Renamed from search_products for consistency **Breaking Changes:** - Import paths changed: tools.inventory → tools.foundation.inventory - Import paths changed: tools.inventory → tools.foundation.items - search_products renamed to search_items **Testing:** - Updated all test imports to new structure - Fixed version assertions (0.1.0a1 → 0.3.0) - All 1663 tests passing - MyPy type checking passed - Ruff linting passed **Files Changed:** - 7 new files (services, foundation, workflows structure) - 5 modified files (server.py, tools/__init__.py, tests) - 1 renamed file (inventory.py → foundation/inventory.py) **Benefits:** - Matches production-proven StockTrim patterns - Cleaner dependency injection with get_services() - Better organization for scaling to more tools - Unified item interface - huge UX win for users Closes Phase 1 of StockTrim migration (#112) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 043e70b commit 8645d9d

12 files changed

Lines changed: 623 additions & 205 deletions

File tree

katana_mcp_server/src/katana_mcp/server.py

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
import os
1515
from collections.abc import AsyncIterator
1616
from contextlib import asynccontextmanager
17+
from dataclasses import dataclass
1718
from typing import Any
1819

1920
from dotenv import load_dotenv
@@ -30,16 +31,18 @@
3031
logger = logging.getLogger(__name__)
3132

3233

34+
@dataclass
3335
class ServerContext:
34-
"""Context object that holds the KatanaClient instance for the server lifespan."""
36+
"""Context object that holds the KatanaClient instance for the server lifespan.
3537
36-
def __init__(self, client: KatanaClient):
37-
"""Initialize server context with KatanaClient.
38+
This dataclass provides type-safe access to the KatanaClient throughout
39+
the server lifecycle, following the StockTrim architecture pattern.
3840
39-
Args:
40-
client: Initialized KatanaClient instance
41-
"""
42-
self.client = client
41+
Attributes:
42+
client: Initialized KatanaClient instance for API operations
43+
"""
44+
45+
client: KatanaClient
4346

4447

4548
@asynccontextmanager
@@ -95,7 +98,6 @@ async def lifespan(server: FastMCP) -> AsyncIterator[ServerContext]:
9598
logger.info("KatanaClient initialized successfully")
9699

97100
# Create context with client for tools to access
98-
# Note: client is KatanaClient but mypy sees it as AuthenticatedClient
99101
context = ServerContext(client=client) # type: ignore[arg-type]
100102

101103
# Yield context to server - tools can access via lifespan dependency
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
"""Services layer for dependency injection in MCP tools.
2+
3+
This module provides clean dependency injection patterns for accessing
4+
the KatanaClient and other services from MCP tool contexts.
5+
"""
6+
7+
from .dependencies import Services, get_services
8+
9+
__all__ = [
10+
"Services",
11+
"get_services",
12+
]
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
"""Dependency injection helpers for MCP tools.
2+
3+
This module provides a clean pattern for extracting services from the MCP context,
4+
following the StockTrim architecture pattern.
5+
"""
6+
7+
from dataclasses import dataclass
8+
9+
from fastmcp import Context
10+
11+
from katana_public_api_client import KatanaClient
12+
13+
14+
@dataclass
15+
class Services:
16+
"""Container for services available to tools.
17+
18+
This dataclass provides type-safe access to services that tools need.
19+
Currently contains only the KatanaClient, but can be extended with
20+
additional services as needed.
21+
22+
Attributes:
23+
client: The KatanaClient instance for API operations
24+
"""
25+
26+
client: KatanaClient
27+
28+
29+
def get_services(context: Context) -> Services:
30+
"""Extract services from MCP context.
31+
32+
This helper provides a single extraction point for all service dependencies,
33+
making tool implementations cleaner and more testable.
34+
35+
Usage in tools:
36+
```python
37+
services = get_services(context)
38+
39+
# Use existing helpers (variants, products, materials, services, inventory)
40+
products = await services.client.products.list()
41+
42+
# For other endpoints (purchase_orders, sales_orders, etc), use generated API:
43+
from katana_public_api_client.api.purchase_order import (
44+
create_purchase_order,
45+
)
46+
47+
po_response = await create_purchase_order.asyncio_detailed(
48+
client=services.client, json_body=...
49+
)
50+
```
51+
52+
Note:
53+
Only a limited set of helpers currently exist on KatanaClient:
54+
- variants
55+
- products
56+
- materials
57+
- services
58+
- inventory
59+
60+
For other endpoints (purchase_orders, manufacturing_orders, sales_orders),
61+
you must use the generated API modules directly from katana_public_api_client.api.*
62+
or implement your own helper methods.
63+
64+
Args:
65+
context: FastMCP context containing lifespan_context with ServerContext
66+
67+
Returns:
68+
Services: Dataclass containing client and other services
69+
"""
70+
server_context = context.request_context.lifespan_context # type: ignore[attr-defined]
71+
return Services(client=server_context.client) # type: ignore[attr-defined]

katana_mcp_server/src/katana_mcp/tools/__init__.py

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,42 @@
11
"""MCP tools for Katana Manufacturing ERP.
22
3-
This module contains tool implementations that provide actions with side effects
4-
for interacting with the Katana API.
3+
This module contains tool implementations organized into foundation and workflow layers.
4+
5+
Tool Organization:
6+
-----------------
7+
- **Foundation tools** (tools/foundation/): Low-level operations mapping to API endpoints
8+
- items.py: Search and manage items (variants, products, materials, services)
9+
- inventory.py: Stock checking, low stock alerts, inventory operations
10+
11+
- **Workflow tools** (tools/workflows/): High-level intent-based operations
12+
- Coming in Phase 3
513
614
Tool Registration Pattern:
715
--------------------------
816
Each tool module exports a register_tools(mcp) function that registers its tools
917
with the FastMCP instance. This avoids circular imports.
1018
1119
When adding new tool modules:
12-
1. Create the new module (e.g., sales_orders.py)
20+
1. Create the new module (e.g., foundation/purchase_orders.py)
1321
2. Define tools as regular async functions (no decorators)
1422
3. Add a register_tools(mcp: FastMCP) function that calls mcp.tool() on each function
15-
4. Import and call the registration function from server.py
23+
4. Import and call the registration function from foundation/__init__.py or workflows/__init__.py
1624
"""
1725

1826
from fastmcp import FastMCP
1927

20-
from .inventory import register_tools as register_inventory_tools
28+
from .foundation import register_all_foundation_tools
29+
from .workflows import register_all_workflow_tools
2130

2231

2332
def register_all_tools(mcp: FastMCP) -> None:
24-
"""Register all tools from all modules.
33+
"""Register all tools from all modules (foundation + workflow).
2534
2635
Args:
2736
mcp: FastMCP server instance to register tools with
2837
"""
29-
register_inventory_tools(mcp)
38+
register_all_foundation_tools(mcp)
39+
register_all_workflow_tools(mcp)
3040

3141

3242
__all__ = [
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
"""Foundation tools for Katana MCP Server.
2+
3+
Foundation tools are low-level operations that map closely to API endpoints.
4+
They provide granular control and are the building blocks for workflow tools.
5+
6+
Organization:
7+
- items.py: Search and manage items (variants, products, materials, services)
8+
- inventory.py: Stock checking, low stock alerts, inventory operations
9+
"""
10+
11+
from fastmcp import FastMCP
12+
13+
from .inventory import register_tools as register_inventory_tools
14+
from .items import register_tools as register_items_tools
15+
16+
17+
def register_all_foundation_tools(mcp: FastMCP) -> None:
18+
"""Register all foundation tools from all modules.
19+
20+
Args:
21+
mcp: FastMCP server instance to register tools with
22+
"""
23+
register_items_tools(mcp)
24+
register_inventory_tools(mcp)
25+
26+
27+
__all__ = [
28+
"register_all_foundation_tools",
29+
]

katana_mcp_server/src/katana_mcp/tools/inventory.py renamed to katana_mcp_server/src/katana_mcp/tools/foundation/inventory.py

Lines changed: 15 additions & 127 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
1-
"""Inventory management tools for Katana MCP Server."""
1+
"""Inventory management tools for Katana MCP Server.
2+
3+
Foundation tools for checking stock levels, monitoring low stock,
4+
and managing inventory operations.
5+
"""
26

37
from __future__ import annotations
48

@@ -7,6 +11,8 @@
711
from fastmcp import Context, FastMCP
812
from pydantic import BaseModel, Field
913

14+
from katana_mcp.services import get_services
15+
1016
logger = logging.getLogger(__name__)
1117

1218
# ============================================================================
@@ -52,10 +58,9 @@ async def _check_inventory_impl(
5258
logger.info(f"Checking inventory for SKU: {request.sku}")
5359

5460
try:
55-
# Access KatanaClient from lifespan context
56-
server_context = context.request_context.lifespan_context # type: ignore[attr-defined]
57-
client = server_context.client # type: ignore[attr-defined]
58-
product = await client.inventory.check_stock(request.sku)
61+
# Access services using helper
62+
services = get_services(context)
63+
product = await services.client.inventory.check_stock(request.sku)
5964

6065
if not product:
6166
# Product not found - return zero stock
@@ -166,10 +171,11 @@ async def _list_low_stock_items_impl(
166171
)
167172

168173
try:
169-
# Access KatanaClient from lifespan context
170-
server_context = context.request_context.lifespan_context # type: ignore[attr-defined]
171-
client = server_context.client # type: ignore[attr-defined]
172-
products = await client.inventory.list_low_stock(threshold=request.threshold)
174+
# Access services using helper
175+
services = get_services(context)
176+
products = await services.client.inventory.list_low_stock(
177+
threshold=request.threshold
178+
)
173179

174180
# Limit results
175181
limited_products = products[: request.limit]
@@ -224,123 +230,6 @@ async def list_low_stock_items(
224230
return await _list_low_stock_items_impl(request, context)
225231

226232

227-
# ============================================================================
228-
# Tool 3: search_products
229-
# ============================================================================
230-
231-
232-
class SearchProductsRequest(BaseModel):
233-
"""Request model for searching products."""
234-
235-
query: str = Field(..., description="Search query (name, SKU, etc.)")
236-
limit: int = Field(default=20, description="Maximum results to return")
237-
238-
239-
class ProductInfo(BaseModel):
240-
"""Product information."""
241-
242-
id: int
243-
sku: str
244-
name: str
245-
is_sellable: bool
246-
stock_level: int | None = None
247-
248-
249-
class SearchProductsResponse(BaseModel):
250-
"""Response containing search results."""
251-
252-
products: list[ProductInfo]
253-
total_count: int
254-
255-
256-
async def _search_products_impl(
257-
request: SearchProductsRequest, context: Context
258-
) -> SearchProductsResponse:
259-
"""Implementation of search_products tool.
260-
261-
Args:
262-
request: Request with search query and limit
263-
context: Server context with KatanaClient
264-
265-
Returns:
266-
List of matching product variants with extended names
267-
268-
Raises:
269-
ValueError: If query is empty or limit is invalid
270-
Exception: If API call fails
271-
"""
272-
if not request.query or not request.query.strip():
273-
raise ValueError("Search query cannot be empty")
274-
if request.limit <= 0:
275-
raise ValueError("Limit must be positive")
276-
277-
logger.info(
278-
f"Searching variants for query: '{request.query}' (limit={request.limit})"
279-
)
280-
281-
try:
282-
# Access KatanaClient from lifespan context
283-
server_context = context.request_context.lifespan_context # type: ignore[attr-defined]
284-
client = server_context.client # type: ignore[attr-defined]
285-
286-
# Search variants (which have SKUs) with parent product/material info
287-
variants = await client.variants.search(request.query, limit=request.limit)
288-
289-
# Build response - format names matching Katana UI
290-
products_info = []
291-
for variant in variants:
292-
# Build variant name using domain model method
293-
# Format: "Product Name / Config1 / Config2 / ..."
294-
name = variant.get_display_name()
295-
296-
# Determine if variant is sellable (products are sellable, materials are not)
297-
is_sellable = variant.type_ == "product" if variant.type_ else False
298-
299-
products_info.append(
300-
ProductInfo(
301-
id=variant.id,
302-
sku=variant.sku or "",
303-
name=name,
304-
is_sellable=is_sellable,
305-
stock_level=None, # Variants don't have stock_level directly
306-
)
307-
)
308-
309-
response = SearchProductsResponse(
310-
products=products_info,
311-
total_count=len(products_info),
312-
)
313-
314-
logger.info(f"Found {response.total_count} variants matching '{request.query}'")
315-
return response
316-
317-
except Exception as e:
318-
logger.error(f"Failed to search products for query '{request.query}': {e}")
319-
raise
320-
321-
322-
async def search_products(
323-
request: SearchProductsRequest, context: Context
324-
) -> SearchProductsResponse:
325-
"""Search for products by name or SKU.
326-
327-
Performs a search across product catalog to find items matching
328-
the search query. Useful for quick product lookup.
329-
330-
Args:
331-
request: Request with search query and limit
332-
context: Server context with KatanaClient
333-
334-
Returns:
335-
List of matching products with basic info
336-
337-
Example:
338-
Request: {"query": "widget", "limit": 10}
339-
Returns: {"products": [...], "total_count": 5}
340-
"""
341-
return await _search_products_impl(request, context)
342-
343-
344233
def register_tools(mcp: FastMCP) -> None:
345234
"""Register all inventory tools with the FastMCP instance.
346235
@@ -349,4 +238,3 @@ def register_tools(mcp: FastMCP) -> None:
349238
"""
350239
mcp.tool()(check_inventory)
351240
mcp.tool()(list_low_stock_items)
352-
mcp.tool()(search_products)

0 commit comments

Comments
 (0)