Skip to content

Commit dcd038e

Browse files
Merge pull request #14 from inventree/attachment-and-parameter
Support Attachment and Parameter models
2 parents ad6c0b4 + 62b015a commit dcd038e

7 files changed

Lines changed: 472 additions & 6 deletions

File tree

README.md

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,18 @@ 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
2020
orders, build orders (each with list + detail, plus line items, and - for sales/build orders -
21-
stock allocations), companies, contacts, addresses, manufacturer parts, and supplier parts. No
22-
write tools are implemented yet - and when they are, the `MCP_READ_ONLY` setting (see
23-
Configuration below) blocks any write action by default regardless of the calling user's
24-
permissions, as a second layer on top of per-user roles.
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.
26+
27+
Attachments and parameters are generic - they can be linked to almost any InvenTree record (a
28+
part, a stock item, an order, ...) rather than being tied to one resource type - see
29+
[`inventree_mcp/tools/attachments.py`](inventree_mcp/tools/attachments.py) and
30+
[`inventree_mcp/tools/parameters.py`](inventree_mcp/tools/parameters.py) for the exact
31+
`model_type`/`model_id` scoping mechanism (the two use different `model_type` string formats -
32+
documented in each module).
2533

2634
Each `outputSchema` is generated from the real InvenTree serializer (not hand-maintained), so it
2735
can't drift from the actual API shape as InvenTree evolves. Every list tool also takes `ordering`

inventree_mcp/mcp_server.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,12 +29,14 @@
2929
# already-registered tools. See output_schemas.py.
3030
from . import output_schemas
3131
from .tools import ( # noqa: F401
32+
attachments,
3233
bom,
3334
build_orders,
3435
categories,
3536
companies,
3637
discovery,
3738
locations,
39+
parameters,
3840
parts,
3941
purchase_orders,
4042
sales_orders,

inventree_mcp/output_schemas.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,11 @@
3333
from typing import Any
3434

3535
from build.serializers import BuildItemSerializer, BuildLineSerializer, BuildSerializer
36+
from common.serializers import (
37+
AttachmentSerializer,
38+
ParameterSerializer,
39+
ParameterTemplateSerializer,
40+
)
3641
from company.serializers import (
3742
AddressSerializer,
3843
CompanySerializer,
@@ -100,6 +105,12 @@
100105
"get_bom_item": serializer_schema(BomItemSerializer),
101106
"list_bom_substitutes": paginated_schema(BomItemSubstituteSerializer),
102107
"get_bom_substitute": serializer_schema(BomItemSubstituteSerializer),
108+
"list_attachments": paginated_schema(AttachmentSerializer),
109+
"get_attachment": serializer_schema(AttachmentSerializer),
110+
"list_parameters": paginated_schema(ParameterSerializer),
111+
"get_parameter": serializer_schema(ParameterSerializer),
112+
"list_parameter_templates": paginated_schema(ParameterTemplateSerializer),
113+
"get_parameter_template": serializer_schema(ParameterTemplateSerializer),
103114
}
104115

105116

inventree_mcp/test_mcp.py

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,10 @@
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
2122
from company.models import Address, Company, Contact, ManufacturerPart, SupplierPart
2223
from django.contrib.auth import get_user_model
24+
from django.contrib.contenttypes.models import ContentType
2325
from django.test import Client, override_settings
2426
from django.utils import timezone
2527
from InvenTree.unit_test import InvenTreeTestCase
@@ -47,6 +49,7 @@
4749
from .schema_introspection import paginated_schema, serializer_schema
4850
from .settings import get_plugin_setting
4951
from .tools._common import DEFAULT_LIMIT, MAX_LIMIT, build_query_params, clamp_limit
52+
from .tools.attachments import get_attachment, list_attachments
5053
from .tools.bom import (
5154
get_bom_item,
5255
get_bom_substitute,
@@ -72,6 +75,12 @@
7275
)
7376
from .tools.discovery import describe_filters
7477
from .tools.locations import get_location, list_locations
78+
from .tools.parameters import (
79+
get_parameter,
80+
get_parameter_template,
81+
list_parameter_templates,
82+
list_parameters,
83+
)
7584
from .tools.parts import get_part, list_parts
7685
from .tools.purchase_orders import (
7786
get_purchase_order,
@@ -234,6 +243,30 @@ def setUpTestData(cls):
234243
bom_item=cls.bom_item, part=cls.substitute_part
235244
)
236245

246+
# --- Attachment/Parameter fixtures ---
247+
# Both model_type fields are real ContentType FKs at the ORM level,
248+
# despite each serializing over the wire as a plain string in a
249+
# *different* format per resource - see tools/attachments.py's and
250+
# tools/parameters.py's module docstrings for why they're not
251+
# interchangeable.
252+
cls.attachment = Attachment.objects.create(
253+
model_type="part",
254+
model_id=cls.part.pk,
255+
link="https://example.org/datasheet.pdf",
256+
comment="Test datasheet",
257+
)
258+
cls.parameter_template = ParameterTemplate.objects.create(
259+
name="Resistance",
260+
units="ohm",
261+
model_type=ContentType.objects.get_for_model(Part),
262+
)
263+
cls.parameter = Parameter.objects.create(
264+
model_type=ContentType.objects.get_for_model(Part),
265+
model_id=cls.part.pk,
266+
template=cls.parameter_template,
267+
data="100",
268+
)
269+
237270
# A second user, deliberately given no roles at all.
238271
cls.no_access_user = get_user_model().objects.create_user(
239272
username="noaccess", password="password", email="noaccess@example.org"
@@ -393,6 +426,9 @@ async def test_ordering_argument_reaches_every_list_tool(self):
393426
"supplier_part": list_supplier_parts,
394427
"bom_item": list_bom_items,
395428
"bom_substitute": list_bom_substitutes,
429+
"attachment": list_attachments,
430+
"parameter": list_parameters,
431+
"parameter_template": list_parameter_templates,
396432
}
397433

398434
for resource, tool_fn in list_tools_by_resource.items():
@@ -711,6 +747,82 @@ async def test_unauthorized_user_cannot_access_bom_data(self):
711747
with self.assertRaises(ToolError):
712748
await get_bom_substitute(self.bom_substitute.pk)
713749

750+
async def test_authorized_user_can_list_and_get_attachments(self):
751+
self._as(self.user)
752+
753+
listed = await list_attachments(model_type="part", model_id=self.part.pk)
754+
ids = [a["pk"] for a in listed["results"]]
755+
self.assertIn(self.attachment.pk, ids)
756+
757+
detail = await get_attachment(self.attachment.pk)
758+
self.assertEqual(detail["comment"], "Test datasheet")
759+
760+
# cls.attachment is a link, not an uploaded file, so is_image=False
761+
# must include it and is_image=True must exclude it.
762+
not_images = await list_attachments(is_image=False)
763+
self.assertIn(self.attachment.pk, [a["pk"] for a in not_images["results"]])
764+
images_only = await list_attachments(is_image=True)
765+
self.assertNotIn(self.attachment.pk, [a["pk"] for a in images_only["results"]])
766+
767+
async def test_unauthorized_user_can_still_read_attachments(self):
768+
"""Deliberately the opposite assertion from every other resource's denial test.
769+
770+
AttachmentList/Detail have no RolePermission/RuleSet gate on reads -
771+
only IsAuthenticatedOrReadScope (any authenticated user) - see
772+
tools/attachments.py's module docstring. A zero-role user must still
773+
succeed here; asserting ToolError (the pattern used everywhere else
774+
in this file) would be testing for the wrong thing and would mask a
775+
real regression if this view's permissions ever tightened.
776+
"""
777+
self._as(self.no_access_user)
778+
779+
listed = await list_attachments(model_type="part", model_id=self.part.pk)
780+
ids = [a["pk"] for a in listed["results"]]
781+
self.assertIn(self.attachment.pk, ids)
782+
783+
detail = await get_attachment(self.attachment.pk)
784+
self.assertEqual(detail["pk"], self.attachment.pk)
785+
786+
async def test_authorized_user_can_list_and_get_parameters(self):
787+
self._as(self.user)
788+
789+
listed = await list_parameters(model_type="part.part", model_id=self.part.pk)
790+
ids = [p["pk"] for p in listed["results"]]
791+
self.assertIn(self.parameter.pk, ids)
792+
793+
detail = await get_parameter(self.parameter.pk)
794+
self.assertEqual(detail["data"], "100")
795+
796+
by_template = await list_parameters(template=self.parameter_template.pk)
797+
self.assertIn(self.parameter.pk, [p["pk"] for p in by_template["results"]])
798+
799+
async def test_authorized_user_can_list_and_get_parameter_templates(self):
800+
self._as(self.user)
801+
802+
listed = await list_parameter_templates(search="Resistance")
803+
names = [t["name"] for t in listed["results"]]
804+
self.assertIn("Resistance", names)
805+
806+
detail = await get_parameter_template(self.parameter_template.pk)
807+
self.assertEqual(detail["units"], "ohm")
808+
809+
async def test_unauthorized_user_can_still_read_parameters(self):
810+
"""Same "opposite of every other resource" case as attachments, above -
811+
ParameterList/Detail and ParameterTemplateList/Detail have no
812+
RolePermission/RuleSet gate on reads either.
813+
"""
814+
self._as(self.no_access_user)
815+
816+
listed = await list_parameters(model_type="part.part", model_id=self.part.pk)
817+
ids = [p["pk"] for p in listed["results"]]
818+
self.assertIn(self.parameter.pk, ids)
819+
820+
detail = await get_parameter(self.parameter.pk)
821+
self.assertEqual(detail["pk"], self.parameter.pk)
822+
823+
templates = await list_parameter_templates()
824+
self.assertTrue(templates["results"])
825+
714826
async def test_authorized_user_can_list_and_get_companies(self):
715827
self._as(self.user)
716828

@@ -956,6 +1068,20 @@ def test_describe_filters_covers_bom_resources(self):
9561068
{"part": {"type": "integer (id)"}, "bom_item": {"type": "integer (id)"}},
9571069
)
9581070

1071+
def test_describe_filters_covers_attachment_and_parameter_resources(self):
1072+
attachment_filters = describe_filters("attachment")["filters"]
1073+
self.assertIn("model_type", attachment_filters)
1074+
self.assertIn("model_id", attachment_filters)
1075+
self.assertIn("is_image", attachment_filters)
1076+
1077+
parameter_filters = describe_filters("parameter")["filters"]
1078+
self.assertIn("model_id", parameter_filters)
1079+
self.assertIn("template", parameter_filters)
1080+
1081+
template_filters = describe_filters("parameter_template")["filters"]
1082+
self.assertIn("units", template_filters)
1083+
self.assertIn("has_choices", template_filters)
1084+
9591085
def test_describe_filters_covers_filterset_fields_shorthand(self):
9601086
"""Contact/AddressList use DRF's filterset_fields shorthand, not a full
9611087
filterset_class - regression test for the model-field fallback in

inventree_mcp/tools/attachments.py

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
"""MCP tools for querying InvenTree Attachment data.
2+
3+
Attachments are generic: a single model (`common.models.Attachment`) links a
4+
file or external URL to almost any other InvenTree record via a
5+
(`model_type`, `model_id`) pair, rather than each resource having its own
6+
attachment table. See list_attachments' docstring for the exact
7+
`model_type` string format this uses, and note it differs from
8+
`parameters.py`'s.
9+
"""
10+
11+
from __future__ import annotations
12+
13+
from typing import Any
14+
15+
from ..mcp_server import mcp
16+
from ..proxy import call_view
17+
from ._common import build_query_params
18+
19+
20+
@mcp.tool()
21+
async def list_attachments(
22+
model_type: str | None = None,
23+
model_id: int | None = None,
24+
is_image: bool | None = None,
25+
ordering: str | None = None,
26+
filters: dict[str, Any] | None = None,
27+
limit: int = 25,
28+
offset: int = 0,
29+
) -> dict:
30+
"""List attachments (uploaded files or external links) linked to InvenTree records.
31+
32+
Attachments can be linked to almost any InvenTree record - parts, stock
33+
items, purchase/sales/return/transfer orders, builds, companies,
34+
manufacturer/supplier parts, sales order shipments. Pass both
35+
`model_type` and `model_id` together to see everything attached to one
36+
specific record, e.g. "what's attached to part 42?".
37+
38+
Returns a paginated envelope: {count, next, previous, results}. Each
39+
entry in `results` has the same shape as get_attachment's return value -
40+
use its `pk` field with get_attachment to fetch full detail for one of
41+
them. The `attachment`/`thumbnail` fields are URLs to the file, not
42+
embedded file content - this tool cannot return raw file bytes.
43+
44+
Args:
45+
model_type: restrict to attachments on this record type - a plain
46+
lowercase model name, e.g. "part", "stockitem", "build",
47+
"company", "purchaseorder", "salesorder", "returnorder",
48+
"transferorder", "manufacturerpart", "supplierpart",
49+
"salesordershipment". Note this is a *different* string format
50+
from list_parameters' `model_type` (which uses
51+
"app_label.modelname") - the two generic-metadata systems don't
52+
share a format.
53+
model_id: restrict to attachments on this specific record ID (use
54+
together with model_type to scope to one record).
55+
is_image: True for only attachments with a generated thumbnail
56+
(image files), False for the inverse. Omit to include both. See
57+
also filters={"is_file": true} / filters={"is_link": true} to
58+
distinguish uploaded files from external URL links (a link
59+
attachment is never an image).
60+
ordering: field to sort results by ('-' prefix for descending, omit
61+
it for ascending). Call describe_filters("attachment") and
62+
check its ordering_fields list for valid values - an
63+
unrecognized field is silently ignored (no error, no sort)
64+
rather than rejected.
65+
filters: additional filter parameters beyond the named arguments
66+
above - call describe_filters("attachment") to see what's
67+
available, e.g. filters={"upload_user": <id>}.
68+
limit: maximum number of results to return (capped at 100).
69+
offset: pagination offset.
70+
"""
71+
from common.api import AttachmentList
72+
73+
base: dict[str, Any] = {}
74+
if model_type is not None:
75+
base["model_type"] = model_type
76+
if model_id is not None:
77+
base["model_id"] = model_id
78+
if is_image is not None:
79+
base["is_image"] = is_image
80+
if ordering is not None:
81+
base["ordering"] = ordering
82+
83+
params = build_query_params(base, filters, limit, offset)
84+
85+
return await call_view(
86+
AttachmentList, "GET", "/api/attachment/", query_params=params
87+
)
88+
89+
90+
@mcp.tool()
91+
async def get_attachment(attachment_id: int) -> dict:
92+
"""Get full detail for a single attachment by its ID.
93+
94+
Returns the same object shape as one entry in list_attachments's
95+
`results` array. Get a valid ID from list_attachments (its `pk` field)
96+
if you don't already have one.
97+
98+
Args:
99+
attachment_id: the Attachment's database ID.
100+
101+
Raises:
102+
ToolError: no attachment exists with that ID, or the caller doesn't
103+
have permission to view it.
104+
"""
105+
from common.api import AttachmentDetail
106+
107+
return await call_view(
108+
AttachmentDetail, "GET", f"/api/attachment/{attachment_id}/", pk=attachment_id
109+
)

0 commit comments

Comments
 (0)