Skip to content

Commit 062fedd

Browse files
Doug Borgclaude
andcommitted
feat(mcp): implement remaining MCP resources for inventory and orders
Implement 4 additional MCP resources to complete v0.1.0 resource coverage: **Inventory Resources:** - katana://inventory/stock-movements - Unified view of stock transfers and adjustments - Fetches recent transfers and adjustments via generated API - Aggregates into chronological movement feed - Provides summary statistics by movement type **Order Resources (all 3):** - katana://sales-orders - Customer orders with status and delivery info - katana://purchase-orders - Supplier orders with expected delivery - katana://manufacturing-orders - Production orders with progress tracking **Implementation Details:** - All resources use generated Katana API (no helpers needed) - Consistent pattern: fetch → parse → aggregate → summarize - Pydantic models for type-safe response structures - Comprehensive error handling and logging - Next-actions suggestions for each resource **Key Technical Notes:** - Use generated API directly: get_all_sales_orders, find_purchase_orders, get_all_manufacturing_orders - No `from __future__ import annotations` (FastMCP Context requirement) - Response parsing: response.parsed for list data - Status aggregation via dict counters **Resources Implemented (5 total):** 1. ✅ katana://inventory/items (previous commit) 2. ✅ katana://inventory/stock-movements (new) 3. ✅ katana://sales-orders (new) 4. ✅ katana://purchase-orders (new) 5. ✅ katana://manufacturing-orders (new) **Deferred:** katana://inventory/stock-adjustments (redundant with stock-movements for v0.1.0) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 911b0ef commit 062fedd

3 files changed

Lines changed: 751 additions & 4 deletions

File tree

katana_mcp_server/src/katana_mcp/resources/inventory.py

Lines changed: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,253 @@ async def get_inventory_items(context: Context) -> dict:
243243
return response.model_dump()
244244

245245

246+
# ============================================================================
247+
# Resource 2: katana://inventory/stock-movements
248+
# ============================================================================
249+
250+
251+
class StockMovementsSummary(BaseModel):
252+
"""Summary statistics for stock movements."""
253+
254+
total_movements: int = Field(..., description="Total number of movements")
255+
movements_in_response: int = Field(
256+
..., description="Number of movements in this response"
257+
)
258+
movement_types: dict[str, int] = Field(
259+
..., description="Count of movements by type (transfer, adjustment)"
260+
)
261+
262+
263+
class StockMovementsResource(BaseModel):
264+
"""Response structure for stock movements resource."""
265+
266+
generated_at: str = Field(
267+
..., description="ISO timestamp when resource was generated"
268+
)
269+
summary: StockMovementsSummary = Field(..., description="Summary statistics")
270+
movements: list[dict] = Field(
271+
..., description="List of recent stock movements (transfers and adjustments)"
272+
)
273+
next_actions: list[str] = Field(
274+
default_factory=list, description="Suggested next actions"
275+
)
276+
277+
278+
async def _get_stock_movements_impl(context: Context) -> StockMovementsResource:
279+
"""Implementation of stock movements resource.
280+
281+
Fetches recent stock transfers and adjustments from Katana and aggregates
282+
them into a unified view of inventory movements.
283+
284+
Args:
285+
context: FastMCP context for accessing the Katana client
286+
287+
Returns:
288+
Structured stock movements data with summary and movements list
289+
290+
Raises:
291+
Exception: If API calls fail
292+
"""
293+
logger.info("stock_movements_resource_started")
294+
start_time = time.monotonic()
295+
296+
try:
297+
services = get_services(context)
298+
299+
# Import the generated API functions
300+
from katana_public_api_client.api.stock_adjustment import (
301+
get_all_stock_adjustments,
302+
)
303+
from katana_public_api_client.api.stock_transfer import (
304+
get_all_stock_transfers,
305+
)
306+
307+
# Fetch recent stock transfers and adjustments
308+
# TODO: Consider parallelizing with asyncio.gather() for better performance
309+
transfers_response = await get_all_stock_transfers.asyncio_detailed(
310+
client=services.client, limit=50
311+
)
312+
adjustments_response = await get_all_stock_adjustments.asyncio_detailed(
313+
client=services.client, limit=50
314+
)
315+
316+
# Parse responses - extract data from Response objects
317+
transfers = transfers_response.parsed if transfers_response.parsed else []
318+
adjustments = adjustments_response.parsed if adjustments_response.parsed else []
319+
320+
# Aggregate into unified movements list
321+
movements = []
322+
323+
# Add transfers
324+
for transfer in transfers:
325+
movements.append(
326+
{
327+
"id": transfer.id if hasattr(transfer, "id") else None,
328+
"timestamp": (
329+
transfer.transfer_date.isoformat()
330+
if hasattr(transfer, "transfer_date") and transfer.transfer_date
331+
else (
332+
transfer.updated_at.isoformat()
333+
if hasattr(transfer, "updated_at") and transfer.updated_at
334+
else None
335+
)
336+
),
337+
"type": "transfer",
338+
"number": (
339+
transfer.stock_transfer_number
340+
if hasattr(transfer, "stock_transfer_number")
341+
else None
342+
),
343+
"source_location_id": (
344+
transfer.source_location_id
345+
if hasattr(transfer, "source_location_id")
346+
else None
347+
),
348+
"target_location_id": (
349+
transfer.target_location_id
350+
if hasattr(transfer, "target_location_id")
351+
else None
352+
),
353+
"status": (
354+
transfer.status.value
355+
if hasattr(transfer, "status") and transfer.status
356+
else None
357+
),
358+
"notes": (
359+
transfer.additional_info
360+
if hasattr(transfer, "additional_info")
361+
else None
362+
),
363+
}
364+
)
365+
366+
# Add adjustments
367+
for adjustment in adjustments:
368+
movements.append(
369+
{
370+
"id": adjustment.id if hasattr(adjustment, "id") else None,
371+
"timestamp": (
372+
adjustment.adjustment_date.isoformat()
373+
if hasattr(adjustment, "adjustment_date")
374+
and adjustment.adjustment_date
375+
else (
376+
adjustment.updated_at.isoformat()
377+
if hasattr(adjustment, "updated_at")
378+
and adjustment.updated_at
379+
else None
380+
)
381+
),
382+
"type": "adjustment",
383+
"number": (
384+
adjustment.stock_adjustment_number
385+
if hasattr(adjustment, "stock_adjustment_number")
386+
else None
387+
),
388+
"location_id": (
389+
adjustment.location_id
390+
if hasattr(adjustment, "location_id")
391+
else None
392+
),
393+
"reference_no": (
394+
adjustment.reference_no
395+
if hasattr(adjustment, "reference_no")
396+
else None
397+
),
398+
"status": (
399+
adjustment.status.value
400+
if hasattr(adjustment, "status") and adjustment.status
401+
else None
402+
),
403+
"notes": (
404+
adjustment.additional_info
405+
if hasattr(adjustment, "additional_info")
406+
else None
407+
),
408+
}
409+
)
410+
411+
# Sort by timestamp (most recent first)
412+
movements.sort(key=lambda m: m.get("timestamp") or "", reverse=True)
413+
414+
# Count movement types
415+
movement_types = {"transfer": len(transfers), "adjustment": len(adjustments)}
416+
417+
# Build summary
418+
summary = StockMovementsSummary(
419+
total_movements=len(transfers) + len(adjustments),
420+
movements_in_response=len(movements),
421+
movement_types=movement_types,
422+
)
423+
424+
duration_ms = round((time.monotonic() - start_time) * 1000, 2)
425+
logger.info(
426+
"stock_movements_resource_completed",
427+
total_movements=summary.total_movements,
428+
duration_ms=duration_ms,
429+
)
430+
431+
return StockMovementsResource(
432+
generated_at=datetime.now(UTC).isoformat(),
433+
summary=summary,
434+
movements=movements,
435+
next_actions=[
436+
"Review recent adjustments for accuracy",
437+
"Check transfer statuses for pending movements",
438+
"Audit patterns in stock movements",
439+
],
440+
)
441+
442+
except Exception as e:
443+
duration_ms = round((time.monotonic() - start_time) * 1000, 2)
444+
logger.error(
445+
"stock_movements_resource_failed",
446+
error=str(e),
447+
error_type=type(e).__name__,
448+
duration_ms=duration_ms,
449+
exc_info=True,
450+
)
451+
raise
452+
453+
454+
async def get_stock_movements(context: Context) -> dict:
455+
"""Get stock movements resource.
456+
457+
Provides unified view of recent inventory movements including stock transfers
458+
between locations and manual stock adjustments.
459+
460+
**Resource URI:** `katana://inventory/stock-movements`
461+
462+
**Purpose:** Track inventory changes and audit trail
463+
464+
**Refresh Rate:** On-demand (no caching in v0.1.0)
465+
466+
**Data Includes:**
467+
- Recent stock transfers between locations
468+
- Manual stock adjustments
469+
- Movement timestamps and statuses
470+
- Location information
471+
- Reference numbers and notes
472+
473+
**Use Cases:**
474+
- Monitor recent inventory activity
475+
- Audit stock changes
476+
- Track transfer status
477+
- Investigate discrepancies
478+
479+
**Related Tools:**
480+
- `check_inventory` - Get current stock levels
481+
- `create_purchase_order` - Order more stock
482+
483+
Args:
484+
context: FastMCP context providing access to Katana client
485+
486+
Returns:
487+
Dictionary containing stock movements data with summary and movements list
488+
"""
489+
response = await _get_stock_movements_impl(context)
490+
return response.model_dump()
491+
492+
246493
def register_resources(mcp: FastMCP) -> None:
247494
"""Register all inventory resources with the FastMCP instance.
248495
@@ -257,5 +504,13 @@ def register_resources(mcp: FastMCP) -> None:
257504
mime_type="application/json",
258505
)(get_inventory_items)
259506

507+
# Register katana://inventory/stock-movements resource
508+
mcp.resource(
509+
uri="katana://inventory/stock-movements",
510+
name="Stock Movements",
511+
description="Recent inventory movements (transfers and adjustments)",
512+
mime_type="application/json",
513+
)(get_stock_movements)
514+
260515

261516
__all__ = ["register_resources"]

0 commit comments

Comments
 (0)