Skip to content

Commit 4ab7eff

Browse files
Merge pull request #15 from inventree/more-models
Add support for more model types:
2 parents dcd038e + ec1ed06 commit 4ab7eff

9 files changed

Lines changed: 689 additions & 15 deletions

File tree

README.md

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,12 @@ than they could via the normal API. Tool code must never query the Django ORM di
1717
tools by wrapping an existing (or new) API view, not by reimplementing queries.
1818

1919
Currently read-only: parts, stock items, stock locations, part categories, purchase orders, sales
20-
orders, build orders (each with list + detail, plus line items, and - for sales/build orders -
21-
stock allocations), companies, contacts, addresses, manufacturer parts, supplier parts, BOM items
22-
and substitutes, attachments, and parameters (with parameter templates). No write tools are
23-
implemented yet - and when they are, the `MCP_READ_ONLY` setting (see Configuration below) blocks
24-
any write action by default regardless of the calling user's permissions, as a second layer on top
25-
of per-user roles.
20+
orders, return orders, build orders (each with list + detail, plus line items, and - for sales/
21+
build orders - stock allocations), companies, contacts, addresses, manufacturer parts, supplier
22+
parts, BOM items and substitutes, attachments, parameters (with parameter templates), stock
23+
tracking history, stock item test results, and project codes. No write tools are implemented yet -
24+
and when they are, the `MCP_READ_ONLY` setting (see Configuration below) blocks any write action by
25+
default regardless of the calling user's permissions, as a second layer on top of per-user roles.
2626

2727
Attachments and parameters are generic - they can be linked to almost any InvenTree record (a
2828
part, a stock item, an order, ...) rather than being tied to one resource type - see

inventree_mcp/mcp_server.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,9 +38,12 @@
3838
locations,
3939
parameters,
4040
parts,
41+
project_codes,
4142
purchase_orders,
43+
return_orders,
4244
sales_orders,
4345
stock,
46+
stock_history,
4447
supplier_parts,
4548
)
4649

inventree_mcp/output_schemas.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
AttachmentSerializer,
3838
ParameterSerializer,
3939
ParameterTemplateSerializer,
40+
ProjectCodeSerializer,
4041
)
4142
from company.serializers import (
4243
AddressSerializer,
@@ -48,6 +49,8 @@
4849
from order.serializers import (
4950
PurchaseOrderLineItemSerializer,
5051
PurchaseOrderSerializer,
52+
ReturnOrderLineItemSerializer,
53+
ReturnOrderSerializer,
5154
SalesOrderAllocationSerializer,
5255
SalesOrderLineItemSerializer,
5356
SalesOrderSerializer,
@@ -59,7 +62,12 @@
5962
PartSerializer,
6063
)
6164
from pydantic import RootModel
62-
from stock.serializers import LocationSerializer, StockItemSerializer
65+
from stock.serializers import (
66+
LocationSerializer,
67+
StockItemSerializer,
68+
StockItemTestResultSerializer,
69+
StockTrackingSerializer,
70+
)
6371

6472
from .mcp_server import mcp
6573
from .schema_introspection import paginated_schema, serializer_schema
@@ -111,6 +119,16 @@
111119
"get_parameter": serializer_schema(ParameterSerializer),
112120
"list_parameter_templates": paginated_schema(ParameterTemplateSerializer),
113121
"get_parameter_template": serializer_schema(ParameterTemplateSerializer),
122+
"list_return_orders": paginated_schema(ReturnOrderSerializer),
123+
"get_return_order": serializer_schema(ReturnOrderSerializer),
124+
"list_return_order_lines": paginated_schema(ReturnOrderLineItemSerializer),
125+
"get_return_order_line": serializer_schema(ReturnOrderLineItemSerializer),
126+
"list_stock_tracking": paginated_schema(StockTrackingSerializer),
127+
"get_stock_tracking": serializer_schema(StockTrackingSerializer),
128+
"list_stock_test_results": paginated_schema(StockItemTestResultSerializer),
129+
"get_stock_test_result": serializer_schema(StockItemTestResultSerializer),
130+
"list_project_codes": paginated_schema(ProjectCodeSerializer),
131+
"get_project_code": serializer_schema(ProjectCodeSerializer),
114132
}
115133

116134

inventree_mcp/test_mcp.py

Lines changed: 175 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
import jsonschema
1919
from asgiref.sync import sync_to_async
2020
from build.models import Build, BuildItem
21-
from common.models import Attachment, Parameter, ParameterTemplate
21+
from common.models import Attachment, Parameter, ParameterTemplate, ProjectCode
2222
from company.models import Address, Company, Contact, ManufacturerPart, SupplierPart
2323
from django.contrib.auth import get_user_model
2424
from django.contrib.contenttypes.models import ContentType
@@ -30,16 +30,23 @@
3030
from order.models import (
3131
PurchaseOrder,
3232
PurchaseOrderLineItem,
33+
ReturnOrder,
34+
ReturnOrderLineItem,
3335
SalesOrder,
3436
SalesOrderAllocation,
3537
SalesOrderLineItem,
3638
SalesOrderShipment,
3739
)
3840
from part.api import PartList
39-
from part.models import BomItem, BomItemSubstitute, Part, PartCategory
41+
from part.models import BomItem, BomItemSubstitute, Part, PartCategory, PartTestTemplate
4042
from part.serializers import PartSerializer
4143
from plugin import registry
42-
from stock.models import StockItem, StockLocation
44+
from stock.models import (
45+
StockItem,
46+
StockItemTestResult,
47+
StockItemTracking,
48+
StockLocation,
49+
)
4350
from users.models import ApiToken
4451

4552
from . import context
@@ -82,12 +89,19 @@
8289
list_parameters,
8390
)
8491
from .tools.parts import get_part, list_parts
92+
from .tools.project_codes import get_project_code, list_project_codes
8593
from .tools.purchase_orders import (
8694
get_purchase_order,
8795
get_purchase_order_line,
8896
list_purchase_order_lines,
8997
list_purchase_orders,
9098
)
99+
from .tools.return_orders import (
100+
get_return_order,
101+
get_return_order_line,
102+
list_return_order_lines,
103+
list_return_orders,
104+
)
91105
from .tools.sales_orders import (
92106
get_sales_order,
93107
get_sales_order_allocation,
@@ -97,6 +111,12 @@
97111
list_sales_orders,
98112
)
99113
from .tools.stock import get_stock_item, list_stock_items
114+
from .tools.stock_history import (
115+
get_stock_test_result,
116+
get_stock_tracking,
117+
list_stock_test_results,
118+
list_stock_tracking,
119+
)
100120
from .tools.supplier_parts import (
101121
get_manufacturer_part,
102122
get_supplier_part,
@@ -116,6 +136,7 @@ class MCPToolPermissionTest(InvenTreeTestCase):
116136
"stock_location.view",
117137
"purchase_order.view",
118138
"sales_order.view",
139+
"return_order.view",
119140
"build.view",
120141
]
121142

@@ -133,6 +154,7 @@ def setUpTestData(cls):
133154
category=cls.category,
134155
salable=True,
135156
purchaseable=True,
157+
testable=True,
136158
)
137159
cls.location = StockLocation.objects.create(
138160
name="Test Location", description="A location for MCP tests"
@@ -267,6 +289,30 @@ def setUpTestData(cls):
267289
data="100",
268290
)
269291

292+
# --- Return order fixtures ---
293+
cls.return_order = ReturnOrder.objects.create(
294+
customer=cls.customer, reference="RMA-MCP-0001"
295+
)
296+
cls.ro_line = ReturnOrderLineItem.objects.create(
297+
order=cls.return_order, item=cls.stock_item
298+
)
299+
300+
# --- Stock tracking / test result fixtures ---
301+
cls.stock_tracking = StockItemTracking.objects.create(
302+
item=cls.stock_item, notes="Test tracking entry"
303+
)
304+
cls.test_template = PartTestTemplate.objects.create(
305+
part=cls.part, test_name="Continuity Test"
306+
)
307+
cls.stock_test_result = StockItemTestResult.objects.create(
308+
stock_item=cls.stock_item, template=cls.test_template, result=True
309+
)
310+
311+
# --- Project code fixtures ---
312+
cls.project_code = ProjectCode.objects.create(
313+
code="MCP-PROJ", description="Test project code"
314+
)
315+
270316
# A second user, deliberately given no roles at all.
271317
cls.no_access_user = get_user_model().objects.create_user(
272318
username="noaccess", password="password", email="noaccess@example.org"
@@ -398,7 +444,7 @@ async def test_ordering_argument_reaches_every_list_tool(self):
398444
"""The deep sort-order tests above only exercise list_stock_items - every other
399445
list tool's own `if ordering is not None: base["ordering"] = ordering` line
400446
still needs at least one real call with ordering set, or it's dead code as
401-
far as the test suite can tell. Spot-checks all 19 list tools at once.
447+
far as the test suite can tell. Spot-checks all 24 list tools at once.
402448
403449
Uses each resource's own real ordering_fields (via describe_filters) rather
404450
than a hardcoded field name per tool, so this can't silently drift out of
@@ -429,6 +475,11 @@ async def test_ordering_argument_reaches_every_list_tool(self):
429475
"attachment": list_attachments,
430476
"parameter": list_parameters,
431477
"parameter_template": list_parameter_templates,
478+
"return_order": list_return_orders,
479+
"return_order_line": list_return_order_lines,
480+
"stock_tracking": list_stock_tracking,
481+
"stock_test_result": list_stock_test_results,
482+
"project_code": list_project_codes,
432483
}
433484

434485
for resource, tool_fn in list_tools_by_resource.items():
@@ -897,6 +948,101 @@ async def test_unauthorized_user_cannot_access_company_catalog_data(self):
897948
with self.assertRaises(ToolError):
898949
await get_supplier_part(self.supplier_part.pk)
899950

951+
async def test_authorized_user_can_list_and_get_return_orders(self):
952+
self._as(self.user)
953+
954+
listed = await list_return_orders(customer=self.customer.pk)
955+
refs = [o["reference"] for o in listed["results"]]
956+
self.assertIn("RMA-MCP-0001", refs)
957+
958+
detail = await get_return_order(self.return_order.pk)
959+
self.assertEqual(detail["reference"], "RMA-MCP-0001")
960+
961+
async def test_authorized_user_can_list_and_get_return_order_lines(self):
962+
self._as(self.user)
963+
964+
listed = await list_return_order_lines(order=self.return_order.pk)
965+
ids = [line["pk"] for line in listed["results"]]
966+
self.assertIn(self.ro_line.pk, ids)
967+
968+
detail = await get_return_order_line(self.ro_line.pk)
969+
self.assertEqual(detail["order"], self.return_order.pk)
970+
971+
async def test_unauthorized_user_cannot_access_return_order_data(self):
972+
self._as(self.no_access_user)
973+
974+
with self.assertRaises(ToolError):
975+
await list_return_orders()
976+
with self.assertRaises(ToolError):
977+
await get_return_order(self.return_order.pk)
978+
with self.assertRaises(ToolError):
979+
await list_return_order_lines()
980+
with self.assertRaises(ToolError):
981+
await get_return_order_line(self.ro_line.pk)
982+
983+
async def test_authorized_user_can_list_and_get_stock_tracking(self):
984+
self._as(self.user)
985+
986+
listed = await list_stock_tracking(item=self.stock_item.pk)
987+
ids = [entry["pk"] for entry in listed["results"]]
988+
self.assertIn(self.stock_tracking.pk, ids)
989+
990+
detail = await get_stock_tracking(self.stock_tracking.pk)
991+
self.assertEqual(detail["item"], self.stock_item.pk)
992+
993+
async def test_authorized_user_can_list_and_get_stock_test_results(self):
994+
self._as(self.user)
995+
996+
listed = await list_stock_test_results(stock_item=self.stock_item.pk)
997+
ids = [r["pk"] for r in listed["results"]]
998+
self.assertIn(self.stock_test_result.pk, ids)
999+
1000+
detail = await get_stock_test_result(self.stock_test_result.pk)
1001+
self.assertEqual(detail["template"], self.test_template.pk)
1002+
1003+
async def test_unauthorized_user_cannot_access_stock_history_data(self):
1004+
"""StockItemTracking/StockItemTestResult are both mapped to the 'stock'
1005+
ruleset (users/ruleset.py) - a zero-role user must be denied here the
1006+
same as list_stock_items.
1007+
"""
1008+
self._as(self.no_access_user)
1009+
1010+
with self.assertRaises(ToolError):
1011+
await list_stock_tracking()
1012+
with self.assertRaises(ToolError):
1013+
await get_stock_tracking(self.stock_tracking.pk)
1014+
with self.assertRaises(ToolError):
1015+
await list_stock_test_results()
1016+
with self.assertRaises(ToolError):
1017+
await get_stock_test_result(self.stock_test_result.pk)
1018+
1019+
async def test_authorized_user_can_list_and_get_project_codes(self):
1020+
self._as(self.user)
1021+
1022+
listed = await list_project_codes(search="MCP-PROJ")
1023+
codes = [c["code"] for c in listed["results"]]
1024+
self.assertIn("MCP-PROJ", codes)
1025+
1026+
detail = await get_project_code(self.project_code.pk)
1027+
self.assertEqual(detail["code"], "MCP-PROJ")
1028+
1029+
async def test_unauthorized_user_can_still_read_project_codes(self):
1030+
"""Deliberately the opposite assertion from most other resources' denial tests.
1031+
1032+
ProjectCodeList/Detail use IsStaffOrReadOnlyScope (see
1033+
tools/project_codes.py's module docstring) - any authenticated user
1034+
can read, not just staff or a specific-role holder. Asserting
1035+
ToolError here (the pattern used everywhere else) would mask a real
1036+
regression if this view's permissions ever tightened.
1037+
"""
1038+
self._as(self.no_access_user)
1039+
1040+
listed = await list_project_codes(search="MCP-PROJ")
1041+
self.assertIn("MCP-PROJ", [c["code"] for c in listed["results"]])
1042+
1043+
detail = await get_project_code(self.project_code.pk)
1044+
self.assertEqual(detail["pk"], self.project_code.pk)
1045+
9001046

9011047
class OutputSchemaTest(InvenTreeTestCase):
9021048
"""Verify tool output schemas are derived from the real serializers, not left blank.
@@ -943,6 +1089,9 @@ async def test_registered_tools_report_output_schemas(self):
9431089
self.assertIsNotNone(tools["get_build_item"].outputSchema)
9441090
self.assertIsNotNone(tools["list_companies"].outputSchema)
9451091
self.assertIsNotNone(tools["get_supplier_part"].outputSchema)
1092+
self.assertIsNotNone(tools["list_return_orders"].outputSchema)
1093+
self.assertIsNotNone(tools["get_stock_tracking"].outputSchema)
1094+
self.assertIsNotNone(tools["list_project_codes"].outputSchema)
9461095

9471096
async def test_every_registered_tool_has_an_output_schema(self):
9481097
"""Guard against a new tool being added without a matching entry in output_schemas.py."""
@@ -1096,6 +1245,28 @@ def test_describe_filters_covers_filterset_fields_shorthand(self):
10961245
{"company": {"type": "integer (id)"}},
10971246
)
10981247

1248+
def test_describe_filters_covers_return_order_resources(self):
1249+
for resource in ("return_order", "return_order_line"):
1250+
result = describe_filters(resource)
1251+
self.assertTrue(result["filters"])
1252+
1253+
self.assertIn("outstanding", describe_filters("return_order")["filters"])
1254+
self.assertIn("received", describe_filters("return_order_line")["filters"])
1255+
1256+
def test_describe_filters_covers_stock_history_resources(self):
1257+
tracking_filters = describe_filters("stock_tracking")["filters"]
1258+
self.assertIn("item", tracking_filters)
1259+
self.assertIn("user", tracking_filters)
1260+
1261+
result_filters = describe_filters("stock_test_result")["filters"]
1262+
self.assertIn("template", result_filters)
1263+
self.assertIn("result", result_filters)
1264+
1265+
def test_describe_filters_covers_project_code(self):
1266+
result = describe_filters("project_code")
1267+
self.assertIn("active", result["filters"])
1268+
self.assertIn("code", result["search_fields"])
1269+
10991270
def test_describe_filters_rejects_unknown_resource(self):
11001271
with self.assertRaises(ToolError):
11011272
describe_filters("not-a-real-resource")

0 commit comments

Comments
 (0)