Skip to content

Commit a16ddb8

Browse files
Merge pull request #35 from inventree/tool-link
Tool link
2 parents 6c8e8e6 + 211b654 commit a16ddb8

4 files changed

Lines changed: 229 additions & 4 deletions

File tree

inventree_mcp/mcp_server.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,23 @@
2525

2626
mcp = MCPServer(
2727
name="InvenTree MCP",
28-
instructions="MCP server for querying InvenTree inventory management data.",
28+
instructions=(
29+
"MCP server for querying InvenTree inventory management data - "
30+
"parts, stock, purchase/sales/return orders, build orders, "
31+
"companies (suppliers/customers/manufacturers) and their catalog "
32+
"parts, BOMs, attachments, and more. Every tool here only reads "
33+
"data (no create/update/delete tools exist yet). "
34+
"Tools follow a list_X/get_X pattern per resource (e.g. "
35+
"list_parts/get_part). To find out more about a resource beyond "
36+
"its tools' named arguments - available search fields, sort "
37+
"fields, extra filters, and optional fields you can inline to save "
38+
"a round trip - call describe_filters(resource) first. "
39+
"When your response mentions a specific record that has a "
40+
"standalone web UI page (e.g. a part, build order, purchase order, "
41+
"stock item, or company - see make_web_link's docstring for the "
42+
"full list), call make_web_link with its type and ID to give the "
43+
"user a clickable link to it."
44+
),
2945
)
3046

3147
# Import tool modules for their side effect of registering @mcp.tool() functions.

inventree_mcp/output_schemas.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,13 @@
129129
"get_stock_test_result": serializer_schema(StockItemTestResultSerializer),
130130
"list_project_codes": paginated_schema(ProjectCodeSerializer),
131131
"get_project_code": serializer_schema(ProjectCodeSerializer),
132+
"make_web_link": {
133+
"type": "object",
134+
"properties": {
135+
"web_url": {"type": ["string", "null"], "format": "uri"},
136+
"error": {"type": "string"},
137+
},
138+
},
132139
}
133140

134141

inventree_mcp/test_mcp.py

Lines changed: 88 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
import datetime
1313
import json
14+
import sys
1415
import unittest
1516
from typing import Any, ClassVar
1617
from unittest.mock import patch
@@ -59,6 +60,7 @@
5960
from .proxy import call_view
6061
from .schema_introspection import paginated_schema, serializer_schema
6162
from .settings import get_plugin_setting
63+
from .tools import discovery
6264
from .tools._common import DEFAULT_LIMIT, MAX_LIMIT, build_query_params, clamp_limit
6365
from .tools.attachments import get_attachment, list_attachments
6466
from .tools.bom import (
@@ -84,7 +86,7 @@
8486
list_companies,
8587
list_contacts,
8688
)
87-
from .tools.discovery import RESOURCE_LOADERS, describe_filters
89+
from .tools.discovery import RESOURCE_LOADERS, describe_filters, make_web_link
8890
from .tools.locations import get_location, list_locations
8991
from .tools.parameters import (
9092
get_parameter,
@@ -1081,6 +1083,22 @@ async def test_unauthorized_user_can_still_read_project_codes(self):
10811083
detail = await get_project_code(self.project_code.pk)
10821084
self.assertEqual(detail["pk"], self.project_code.pk)
10831085

1086+
async def test_make_web_link_builds_a_real_url_end_to_end(self):
1087+
"""Exercise the actual registered async tool (not just the sync helper
1088+
MakeWebLinkTest covers) - confirms the sync_to_async wrapping in
1089+
discovery.make_web_link() doesn't swallow or mis-route either the
1090+
return value or a raised ToolError.
1091+
"""
1092+
result = await make_web_link("purchase_order", self.purchase_order.pk)
1093+
self.assertTrue(
1094+
result["web_url"].endswith(
1095+
f"/web/purchasing/purchase-order/{self.purchase_order.pk}"
1096+
)
1097+
)
1098+
1099+
with self.assertRaises(ToolError):
1100+
await make_web_link("purchase_order_line", self.po_line.pk)
1101+
10841102
async def test_tools_list_reflects_oauth2_scope_narrowing(self):
10851103
"""Regression test for a real design bug caught during development, not a
10861104
hypothetical: a literal HTTP OPTIONS-based capability check (the obvious
@@ -1162,6 +1180,7 @@ async def test_ungated_tools_are_always_visible_to_a_zero_role_user(self):
11621180
names,
11631181
{
11641182
"describe_filters",
1183+
"make_web_link",
11651184
"list_attachments",
11661185
"get_attachment",
11671186
"list_parameters",
@@ -1241,7 +1260,7 @@ async def test_unauthenticated_bound_identity_sees_only_tools_needing_no_auth(se
12411260
tool.name for tool in await mcp.list_tools()
12421261
)
12431262

1244-
self.assertEqual(names, {"describe_filters"})
1263+
self.assertEqual(names, {"describe_filters", "make_web_link"})
12451264

12461265
async def test_unavailable_resource_hides_its_tools_without_crashing(self):
12471266
"""A resource whose loader can't resolve its view class (e.g. a
@@ -1273,7 +1292,14 @@ async def test_every_gated_tool_has_a_visibility_entry(self):
12731292
"""
12741293
all_names = {tool.name for tool in await mcp.list_tools()}
12751294
unmapped = (
1276-
all_names - set(tool_visibility._TOOL_RESOURCES) - {"describe_filters"}
1295+
all_names
1296+
- set(tool_visibility._TOOL_RESOURCES)
1297+
# Both pure metadata/utility tools with no underlying gated view:
1298+
# describe_filters only reads static filterset/serializer
1299+
# definitions, make_web_link only builds a URL string - neither
1300+
# touches the database in a way any RolePermission/RuleSet check
1301+
# applies to.
1302+
- {"describe_filters", "make_web_link"}
12771303
)
12781304

12791305
self.assertEqual(unmapped, set())
@@ -1376,6 +1402,65 @@ def test_caches_a_total_failure_without_re_importing(self):
13761402
mock_import.assert_called_once()
13771403

13781404

1405+
class MakeWebLinkTest(unittest.TestCase):
1406+
"""discovery._build_web_link() must build the right InvenTree web-UI URL, and know when not to.
1407+
1408+
Exercises the sync helper directly rather than the registered async
1409+
make_web_link() tool - same reasoning as ViewResolutionTest testing
1410+
resolve_view() directly: this needs no MCP/DB fixtures, just the plain
1411+
function ToolError.
1412+
"""
1413+
1414+
def test_raises_for_a_resource_with_no_standalone_page(self):
1415+
with self.assertRaises(ToolError):
1416+
discovery._build_web_link("purchase_order_line", 1)
1417+
1418+
def test_returns_an_error_message_without_a_configured_base_url(self):
1419+
with patch("InvenTree.helpers_model.get_base_url", return_value=""):
1420+
result = discovery._build_web_link("part", 5)
1421+
self.assertIsNone(result["web_url"])
1422+
self.assertIn("error", result)
1423+
1424+
def test_returns_an_error_message_on_a_core_version_mismatch(self):
1425+
# Setting a module to None in sys.modules is what actually forces
1426+
# ImportError out of a `from X import Y` statement - patching an
1427+
# attribute on the already-imported module (as
1428+
# test_returns_an_error_message_without_a_configured_base_url does
1429+
# above) can't simulate that, since the `from` import itself would
1430+
# still succeed.
1431+
with patch.dict(sys.modules, {"InvenTree.helpers": None}):
1432+
result = discovery._build_web_link("part", 5)
1433+
self.assertIsNone(result["web_url"])
1434+
self.assertIn("error", result)
1435+
1436+
def test_builds_the_expected_url_per_resource(self):
1437+
with patch("InvenTree.helpers_model.get_base_url", return_value="http://x/"):
1438+
cases = {
1439+
"part": (5, "http://x/web/part/5"),
1440+
"category": (2, "http://x/web/part/category/2"),
1441+
"stock": (7, "http://x/web/stock/item/7"),
1442+
"location": (3, "http://x/web/stock/location/3"),
1443+
"purchase_order": (9, "http://x/web/purchasing/purchase-order/9"),
1444+
"supplier_part": (4, "http://x/web/purchasing/supplier-part/4"),
1445+
"manufacturer_part": (
1446+
6,
1447+
"http://x/web/purchasing/manufacturer-part/6",
1448+
),
1449+
# The generic 'company/{pk}' route, not core's own Company.
1450+
# get_absolute_url() - see _WEB_LINK_PATHS's comment for why.
1451+
"company": (8, "http://x/web/company/8"),
1452+
"sales_order": (1, "http://x/web/sales/sales-order/1"),
1453+
"return_order": (10, "http://x/web/sales/return-order/10"),
1454+
"build_order": (11, "http://x/web/manufacturing/build-order/11"),
1455+
}
1456+
for resource, (pk, expected) in cases.items():
1457+
self.assertEqual(
1458+
discovery._build_web_link(resource, pk)["web_url"],
1459+
expected,
1460+
resource,
1461+
)
1462+
1463+
13791464
class CallViewImportSafetyTest(InvenTreeTestCase):
13801465
"""call_view(None, ...) must raise a clean ToolError, not an AttributeError.
13811466

inventree_mcp/tools/discovery.py

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,17 @@
22

33
from __future__ import annotations
44

5+
import structlog
6+
from asgiref.sync import sync_to_async
57
from mcp.server.mcpserver.exceptions import ToolError
68

79
from ..expand_introspection import describe_output_options
810
from ..filter_introspection import describe_filterset
911
from ..mcp_server import mcp
1012
from ..view_resolution import resolve_view, resolve_view_any
1113

14+
logger = structlog.get_logger("inventree")
15+
1216

1317
def _part_list() -> type | None:
1418
return resolve_view("part.api", "PartList")
@@ -239,3 +243,116 @@ def describe_filters(resource: str) -> dict:
239243
**describe_filterset(view_cls),
240244
"optional_fields": describe_output_options(view_cls),
241245
}
246+
247+
248+
# Relative "web" (React frontend) subpaths, one per resource that has a real
249+
# standalone detail page - matches each model's own get_absolute_url() in
250+
# InvenTree core (order/models.py, part/models.py, stock/models.py,
251+
# company/models.py), *not* imported from there directly: doing so would
252+
# need a real (DB-fetched) model instance just to read a pk-only f-string,
253+
# and Company.get_absolute_url() unconditionally returns
254+
# '/purchasing/manufacturer/{pk}' regardless of whether the company is
255+
# actually a supplier/manufacturer/customer (looks like a core bug -
256+
# verified against src/frontend/src/router.tsx, which also registers a
257+
# generic 'company/:id' route that resolves correctly for any role - used
258+
# here instead of reproducing that bug). manufacturer_part has no
259+
# get_absolute_url() in core at all; its route
260+
# ('purchasing/manufacturer-part/:id') is hardcoded here straight from
261+
# router.tsx. Resources with no standalone page (line items, allocations,
262+
# attachments, and other records only ever shown embedded in a parent's
263+
# page) are deliberately absent - make_web_link() rejects them below.
264+
_WEB_LINK_PATHS: dict[str, str] = {
265+
"part": "/part/{pk}",
266+
"category": "/part/category/{pk}",
267+
"stock": "/stock/item/{pk}",
268+
"location": "/stock/location/{pk}",
269+
"purchase_order": "/purchasing/purchase-order/{pk}",
270+
"supplier_part": "/purchasing/supplier-part/{pk}",
271+
"manufacturer_part": "/purchasing/manufacturer-part/{pk}",
272+
"company": "/company/{pk}",
273+
"sales_order": "/sales/sales-order/{pk}",
274+
"return_order": "/sales/return-order/{pk}",
275+
"build_order": "/manufacturing/build-order/{pk}",
276+
}
277+
278+
279+
def _build_web_link(model_type: str, model_id: int) -> dict:
280+
path_template = _WEB_LINK_PATHS.get(model_type)
281+
282+
if path_template is None:
283+
raise ToolError(
284+
f"{model_type!r} has no standalone web page. Choose one of: "
285+
f"{', '.join(_WEB_LINK_PATHS)}"
286+
)
287+
288+
try:
289+
from InvenTree.helpers import pui_url
290+
from InvenTree.helpers_model import construct_absolute_url, get_base_url
291+
except ImportError as exc:
292+
# Defensive, same reasoning as view_resolution.py: a version
293+
# mismatch between this plugin and the running InvenTree core
294+
# shouldn't crash the tool, just leave it unable to build a link -
295+
# but unlike the "no base URL configured" case below, this is a real
296+
# failure, so log it and tell the caller why, not just null.
297+
logger.warning(
298+
"inventree_mcp: could not import URL-building helpers (%s) - "
299+
"make_web_link will report web_url as unavailable. This usually "
300+
"means the running InvenTree core version doesn't match what "
301+
"this plugin expects.",
302+
exc,
303+
)
304+
return {
305+
"web_url": None,
306+
"error": ("Could not build a web link with the provided information."),
307+
}
308+
309+
if not get_base_url():
310+
return {
311+
"web_url": None,
312+
"error": ("Base URL is not configured for this InvenTree instance."),
313+
}
314+
315+
return {
316+
"web_url": construct_absolute_url(pui_url(path_template.format(pk=model_id)))
317+
}
318+
319+
320+
@mcp.tool()
321+
async def make_web_link(model_type: str, model_id: int) -> dict:
322+
"""Build a clickable InvenTree web UI link for a specific record.
323+
324+
Does not touch any InvenTree data - this only builds a URL string, the
325+
same way describe_filters only describes static metadata. It doesn't
326+
verify model_id actually exists; a valid ID from the matching
327+
list_*/get_* tool always produces a valid link.
328+
329+
Not every resource has a standalone page in the web UI - only the ones
330+
listed below do. A PurchaseOrderLineItem, for example, is only ever
331+
shown embedded in its parent order's page - call this with the parent
332+
purchase_order's ID instead of trying it with a line item.
333+
334+
Args:
335+
model_type: one of "part", "category", "stock", "location",
336+
"purchase_order", "supplier_part", "manufacturer_part",
337+
"company", "sales_order", "return_order", "build_order" -
338+
matches get_part / get_category / get_stock_item / get_location
339+
/ get_purchase_order / get_supplier_part / get_manufacturer_part
340+
/ get_company / get_sales_order / get_return_order /
341+
get_build_order.
342+
model_id: the record's database ID (its `pk` field, from the
343+
matching list_*/get_* tool).
344+
345+
Returns:
346+
{"web_url": <url>} - a full, absolute URL - if this InvenTree
347+
instance has a configured base URL to build one from (SITE_URL /
348+
Django Sites / the INVENTREE_BASE_URL global setting).
349+
{"web_url": null, "error": <message>} otherwise, explaining why no
350+
link could be built - either the server just isn't configured with
351+
a base URL (nothing wrong with the call itself), or the running
352+
InvenTree core version doesn't match what this plugin expects (a
353+
real failure, also logged server-side).
354+
355+
Raises:
356+
ToolError: model_type isn't one of the resources listed above.
357+
"""
358+
return await sync_to_async(_build_web_link)(model_type, model_id)

0 commit comments

Comments
 (0)