inventree-mcp 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- inventree_mcp-0.1.0/LICENSE +21 -0
- inventree_mcp-0.1.0/MANIFEST.in +1 -0
- inventree_mcp-0.1.0/PKG-INFO +123 -0
- inventree_mcp-0.1.0/README.md +104 -0
- inventree_mcp-0.1.0/inventree_mcp/__init__.py +1 -0
- inventree_mcp-0.1.0/inventree_mcp/context.py +78 -0
- inventree_mcp-0.1.0/inventree_mcp/core.py +59 -0
- inventree_mcp-0.1.0/inventree_mcp/expand_introspection.py +49 -0
- inventree_mcp-0.1.0/inventree_mcp/filter_introspection.py +189 -0
- inventree_mcp-0.1.0/inventree_mcp/mcp_server.py +57 -0
- inventree_mcp-0.1.0/inventree_mcp/mcp_transport.py +219 -0
- inventree_mcp-0.1.0/inventree_mcp/oauth2_bridge.py +116 -0
- inventree_mcp-0.1.0/inventree_mcp/output_schemas.py +141 -0
- inventree_mcp-0.1.0/inventree_mcp/proxy.py +231 -0
- inventree_mcp-0.1.0/inventree_mcp/schema_introspection.py +163 -0
- inventree_mcp-0.1.0/inventree_mcp/settings.py +36 -0
- inventree_mcp-0.1.0/inventree_mcp/test_mcp.py +2138 -0
- inventree_mcp-0.1.0/inventree_mcp/tool_logging.py +136 -0
- inventree_mcp-0.1.0/inventree_mcp/tool_visibility.py +183 -0
- inventree_mcp-0.1.0/inventree_mcp/tools/__init__.py +5 -0
- inventree_mcp-0.1.0/inventree_mcp/tools/_common.py +44 -0
- inventree_mcp-0.1.0/inventree_mcp/tools/attachments.py +117 -0
- inventree_mcp-0.1.0/inventree_mcp/tools/bom.py +207 -0
- inventree_mcp-0.1.0/inventree_mcp/tools/build_orders.py +258 -0
- inventree_mcp-0.1.0/inventree_mcp/tools/categories.py +88 -0
- inventree_mcp-0.1.0/inventree_mcp/tools/companies.py +239 -0
- inventree_mcp-0.1.0/inventree_mcp/tools/discovery.py +236 -0
- inventree_mcp-0.1.0/inventree_mcp/tools/locations.py +88 -0
- inventree_mcp-0.1.0/inventree_mcp/tools/parameters.py +202 -0
- inventree_mcp-0.1.0/inventree_mcp/tools/parts.py +93 -0
- inventree_mcp-0.1.0/inventree_mcp/tools/project_codes.py +92 -0
- inventree_mcp-0.1.0/inventree_mcp/tools/purchase_orders.py +185 -0
- inventree_mcp-0.1.0/inventree_mcp/tools/return_orders.py +188 -0
- inventree_mcp-0.1.0/inventree_mcp/tools/sales_orders.py +280 -0
- inventree_mcp-0.1.0/inventree_mcp/tools/stock.py +97 -0
- inventree_mcp-0.1.0/inventree_mcp/tools/stock_history.py +198 -0
- inventree_mcp-0.1.0/inventree_mcp/tools/supplier_parts.py +180 -0
- inventree_mcp-0.1.0/inventree_mcp/view_resolution.py +65 -0
- inventree_mcp-0.1.0/inventree_mcp.egg-info/PKG-INFO +123 -0
- inventree_mcp-0.1.0/inventree_mcp.egg-info/SOURCES.txt +45 -0
- inventree_mcp-0.1.0/inventree_mcp.egg-info/dependency_links.txt +1 -0
- inventree_mcp-0.1.0/inventree_mcp.egg-info/entry_points.txt +2 -0
- inventree_mcp-0.1.0/inventree_mcp.egg-info/requires.txt +4 -0
- inventree_mcp-0.1.0/inventree_mcp.egg-info/top_level.txt +2 -0
- inventree_mcp-0.1.0/pyproject.toml +61 -0
- inventree_mcp-0.1.0/setup.cfg +19 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Oliver Walters <admin@inventree.org>
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
recursive-include inventree_mcp/static *
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: inventree-mcp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: MCP server for InvenTree
|
|
5
|
+
Author-email: Oliver Walters <admin@inventree.org>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/inventree/inventree-mcp
|
|
8
|
+
Keywords: inventree,plugin
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Operating System :: OS Independent
|
|
11
|
+
Classifier: Framework :: InvenTree
|
|
12
|
+
Requires-Python: >=3.9
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
License-File: LICENSE
|
|
15
|
+
Requires-Dist: mcp<3,>=2.0
|
|
16
|
+
Provides-Extra: test
|
|
17
|
+
Requires-Dist: coverage; extra == "test"
|
|
18
|
+
Dynamic: license-file
|
|
19
|
+
|
|
20
|
+
# InvenTreeMCP
|
|
21
|
+
|
|
22
|
+
[](https://github.com/inventree/inventree-mcp/actions/workflows/ci.yaml)
|
|
23
|
+
[](https://codecov.io/gh/inventree/inventree-mcp)
|
|
24
|
+
|
|
25
|
+
An MCP (Model Context Protocol) server for InvenTree, exposed as an InvenTree plugin. It lets MCP
|
|
26
|
+
clients (Claude, other MCP-aware agents) query InvenTree inventory data over a Streamable HTTP
|
|
27
|
+
endpoint.
|
|
28
|
+
|
|
29
|
+
## Design
|
|
30
|
+
|
|
31
|
+
Every tool is a thin wrapper around InvenTree's own REST API view classes (see
|
|
32
|
+
[`inventree_mcp/proxy.py`](inventree_mcp/proxy.py)), dispatched as the authenticated caller - MCP
|
|
33
|
+
requests go through exactly the same permission checks, filtering, and serialization as the regular
|
|
34
|
+
REST API. Tool code never queries the Django ORM directly.
|
|
35
|
+
|
|
36
|
+
Currently read-only, covering parts, stock items/locations, part categories, purchase/sales/return/
|
|
37
|
+
build orders (with line items and allocations), companies, contacts, addresses, manufacturer/
|
|
38
|
+
supplier parts, BOM items, attachments, parameters, stock tracking history, test results, and
|
|
39
|
+
project codes. Once write tools land, the `MCP_READ_ONLY` setting (see Configuration) will block
|
|
40
|
+
them by default regardless of the calling user's permissions.
|
|
41
|
+
|
|
42
|
+
Each tool's `outputSchema` and filter/ordering options are derived live from InvenTree's own
|
|
43
|
+
serializers and views (not hand-maintained), so they can't drift as InvenTree evolves. Call
|
|
44
|
+
`describe_filters(resource)` to see what's available for a given resource. The set of tools an MCP
|
|
45
|
+
client sees is also filtered to what the calling user can actually use - though every call is still
|
|
46
|
+
permission-checked in full regardless of what was advertised.
|
|
47
|
+
|
|
48
|
+
## Setup
|
|
49
|
+
|
|
50
|
+
### 1. Install the plugin
|
|
51
|
+
|
|
52
|
+
Install via the InvenTree plugin manager, or via pip:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
pip install inventree-mcp
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Then enable the plugin under **Admin > Plugins**, and configure its settings (see
|
|
59
|
+
Configuration below).
|
|
60
|
+
|
|
61
|
+
### 2. Create a token for your MCP client
|
|
62
|
+
|
|
63
|
+
Create an InvenTree API token for your MCP client.
|
|
64
|
+
|
|
65
|
+
### 3. Configure your MCP client
|
|
66
|
+
|
|
67
|
+
The endpoint is `<your-inventree-server>/plugin/inventree-mcp/mcp/`, using Streamable HTTP
|
|
68
|
+
transport with an `Authorization: Token <token>` header.
|
|
69
|
+
|
|
70
|
+
For a client that supports remote Streamable HTTP servers directly, add:
|
|
71
|
+
|
|
72
|
+
```json
|
|
73
|
+
{
|
|
74
|
+
"mcpServers": {
|
|
75
|
+
"inventree": {
|
|
76
|
+
"url": "https://<your-inventree-server>/plugin/inventree-mcp/mcp/",
|
|
77
|
+
"headers": {
|
|
78
|
+
"Authorization": "Token <your-api-token>"
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
For a client that only supports local (stdio) servers, bridge it with
|
|
86
|
+
[`mcp-remote`](https://www.npmjs.com/package/mcp-remote):
|
|
87
|
+
|
|
88
|
+
```json
|
|
89
|
+
{
|
|
90
|
+
"mcpServers": {
|
|
91
|
+
"inventree": {
|
|
92
|
+
"command": "npx",
|
|
93
|
+
"args": [
|
|
94
|
+
"mcp-remote",
|
|
95
|
+
"https://<your-inventree-server>/plugin/inventree-mcp/mcp/",
|
|
96
|
+
"--header",
|
|
97
|
+
"Authorization: Token <your-api-token>"
|
|
98
|
+
]
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## Configuration
|
|
105
|
+
|
|
106
|
+
Under **Settings > Plugin Settings**:
|
|
107
|
+
|
|
108
|
+
- **Require Authentication** (`REQUIRE_AUTH`, default `True`): reject unauthenticated requests.
|
|
109
|
+
Only disable for local testing.
|
|
110
|
+
- **Read Only** (`MCP_READ_ONLY`, default `True`): block all write actions via MCP, regardless of
|
|
111
|
+
the calling user's permissions. A plugin-wide kill switch, independent of per-user roles.
|
|
112
|
+
|
|
113
|
+
## Authentication
|
|
114
|
+
|
|
115
|
+
Access follows the calling user's normal InvenTree role assignments. Supported auth methods:
|
|
116
|
+
|
|
117
|
+
- An InvenTree API token: `Authorization: Token <token>`.
|
|
118
|
+
- Basic auth (username/password).
|
|
119
|
+
- An OAuth2 bearer token: `Authorization: Bearer <token>`. A scoped token (e.g. `r:view:part`)
|
|
120
|
+
narrows access *below* the underlying user's roles - useful for issuing an agent a tightly-scoped
|
|
121
|
+
token without creating a separate low-privilege user.
|
|
122
|
+
|
|
123
|
+
Session/cookie auth is not supported (not meaningful for a machine client).
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# InvenTreeMCP
|
|
2
|
+
|
|
3
|
+
[](https://github.com/inventree/inventree-mcp/actions/workflows/ci.yaml)
|
|
4
|
+
[](https://codecov.io/gh/inventree/inventree-mcp)
|
|
5
|
+
|
|
6
|
+
An MCP (Model Context Protocol) server for InvenTree, exposed as an InvenTree plugin. It lets MCP
|
|
7
|
+
clients (Claude, other MCP-aware agents) query InvenTree inventory data over a Streamable HTTP
|
|
8
|
+
endpoint.
|
|
9
|
+
|
|
10
|
+
## Design
|
|
11
|
+
|
|
12
|
+
Every tool is a thin wrapper around InvenTree's own REST API view classes (see
|
|
13
|
+
[`inventree_mcp/proxy.py`](inventree_mcp/proxy.py)), dispatched as the authenticated caller - MCP
|
|
14
|
+
requests go through exactly the same permission checks, filtering, and serialization as the regular
|
|
15
|
+
REST API. Tool code never queries the Django ORM directly.
|
|
16
|
+
|
|
17
|
+
Currently read-only, covering parts, stock items/locations, part categories, purchase/sales/return/
|
|
18
|
+
build orders (with line items and allocations), companies, contacts, addresses, manufacturer/
|
|
19
|
+
supplier parts, BOM items, attachments, parameters, stock tracking history, test results, and
|
|
20
|
+
project codes. Once write tools land, the `MCP_READ_ONLY` setting (see Configuration) will block
|
|
21
|
+
them by default regardless of the calling user's permissions.
|
|
22
|
+
|
|
23
|
+
Each tool's `outputSchema` and filter/ordering options are derived live from InvenTree's own
|
|
24
|
+
serializers and views (not hand-maintained), so they can't drift as InvenTree evolves. Call
|
|
25
|
+
`describe_filters(resource)` to see what's available for a given resource. The set of tools an MCP
|
|
26
|
+
client sees is also filtered to what the calling user can actually use - though every call is still
|
|
27
|
+
permission-checked in full regardless of what was advertised.
|
|
28
|
+
|
|
29
|
+
## Setup
|
|
30
|
+
|
|
31
|
+
### 1. Install the plugin
|
|
32
|
+
|
|
33
|
+
Install via the InvenTree plugin manager, or via pip:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
pip install inventree-mcp
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Then enable the plugin under **Admin > Plugins**, and configure its settings (see
|
|
40
|
+
Configuration below).
|
|
41
|
+
|
|
42
|
+
### 2. Create a token for your MCP client
|
|
43
|
+
|
|
44
|
+
Create an InvenTree API token for your MCP client.
|
|
45
|
+
|
|
46
|
+
### 3. Configure your MCP client
|
|
47
|
+
|
|
48
|
+
The endpoint is `<your-inventree-server>/plugin/inventree-mcp/mcp/`, using Streamable HTTP
|
|
49
|
+
transport with an `Authorization: Token <token>` header.
|
|
50
|
+
|
|
51
|
+
For a client that supports remote Streamable HTTP servers directly, add:
|
|
52
|
+
|
|
53
|
+
```json
|
|
54
|
+
{
|
|
55
|
+
"mcpServers": {
|
|
56
|
+
"inventree": {
|
|
57
|
+
"url": "https://<your-inventree-server>/plugin/inventree-mcp/mcp/",
|
|
58
|
+
"headers": {
|
|
59
|
+
"Authorization": "Token <your-api-token>"
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
For a client that only supports local (stdio) servers, bridge it with
|
|
67
|
+
[`mcp-remote`](https://www.npmjs.com/package/mcp-remote):
|
|
68
|
+
|
|
69
|
+
```json
|
|
70
|
+
{
|
|
71
|
+
"mcpServers": {
|
|
72
|
+
"inventree": {
|
|
73
|
+
"command": "npx",
|
|
74
|
+
"args": [
|
|
75
|
+
"mcp-remote",
|
|
76
|
+
"https://<your-inventree-server>/plugin/inventree-mcp/mcp/",
|
|
77
|
+
"--header",
|
|
78
|
+
"Authorization: Token <your-api-token>"
|
|
79
|
+
]
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## Configuration
|
|
86
|
+
|
|
87
|
+
Under **Settings > Plugin Settings**:
|
|
88
|
+
|
|
89
|
+
- **Require Authentication** (`REQUIRE_AUTH`, default `True`): reject unauthenticated requests.
|
|
90
|
+
Only disable for local testing.
|
|
91
|
+
- **Read Only** (`MCP_READ_ONLY`, default `True`): block all write actions via MCP, regardless of
|
|
92
|
+
the calling user's permissions. A plugin-wide kill switch, independent of per-user roles.
|
|
93
|
+
|
|
94
|
+
## Authentication
|
|
95
|
+
|
|
96
|
+
Access follows the calling user's normal InvenTree role assignments. Supported auth methods:
|
|
97
|
+
|
|
98
|
+
- An InvenTree API token: `Authorization: Token <token>`.
|
|
99
|
+
- Basic auth (username/password).
|
|
100
|
+
- An OAuth2 bearer token: `Authorization: Bearer <token>`. A scoped token (e.g. `r:view:part`)
|
|
101
|
+
narrows access *below* the underlying user's roles - useful for issuing an agent a tightly-scoped
|
|
102
|
+
token without creating a separate low-privilege user.
|
|
103
|
+
|
|
104
|
+
Session/cookie auth is not supported (not meaningful for a machine client).
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
PLUGIN_VERSION = "0.1.0"
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Propagate the authenticated Django user (and OAuth2 auth, if any) into MCP tool execution.
|
|
2
|
+
|
|
3
|
+
Tool functions are invoked by the MCP SDK's dispatcher, which does not know
|
|
4
|
+
about Django requests. The transport view sets the current identity via a
|
|
5
|
+
contextvar before handing the request off to the MCP session manager, so
|
|
6
|
+
tools (and the proxy layer) can recover *who* is calling without threading a
|
|
7
|
+
request object through every tool signature.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from contextvars import ContextVar, Token
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from typing import TYPE_CHECKING, Any
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from django.contrib.auth.models import AbstractUser
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class _Identity:
|
|
22
|
+
user: AbstractUser
|
|
23
|
+
# The real oauth2_provider AccessToken, if the MCP request itself was
|
|
24
|
+
# OAuth2-authenticated - None for token/basic/session auth. Propagated so
|
|
25
|
+
# proxy.call_view() can make the proxied view see the token's *actual*
|
|
26
|
+
# granted scopes, not just the user's role permissions. See proxy.py.
|
|
27
|
+
oauth2_token: Any | None = None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
_current_identity: ContextVar[_Identity | None] = ContextVar(
|
|
31
|
+
"inventree_mcp_current_identity", default=None
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def set_current_user(
|
|
36
|
+
user: AbstractUser | None, oauth2_token: Any | None = None
|
|
37
|
+
) -> Token:
|
|
38
|
+
"""Bind *user* (and, if OAuth2-authenticated, the real access token) for this context."""
|
|
39
|
+
identity = _Identity(user, oauth2_token) if user is not None else None
|
|
40
|
+
return _current_identity.set(identity)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def reset_current_user(token: Token) -> None:
|
|
44
|
+
"""Undo a prior set_current_user call."""
|
|
45
|
+
_current_identity.reset(token)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def has_current_user() -> bool:
|
|
49
|
+
"""Return whether a user is currently bound, without raising if not.
|
|
50
|
+
|
|
51
|
+
For code that needs to behave differently outside a real MCP request
|
|
52
|
+
(e.g. tool_visibility.py, which can't run a permission check without a
|
|
53
|
+
caller to check permissions *for*) rather than treat "no caller" as a
|
|
54
|
+
fatal error the way get_current_user() deliberately does everywhere else.
|
|
55
|
+
"""
|
|
56
|
+
identity = _current_identity.get()
|
|
57
|
+
return identity is not None and identity.user.is_authenticated
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def get_current_user() -> AbstractUser:
|
|
61
|
+
"""Return the user bound to the current MCP request.
|
|
62
|
+
|
|
63
|
+
Raises:
|
|
64
|
+
PermissionError: if no user is bound (e.g. a tool is invoked outside
|
|
65
|
+
of a real MCP request, or authentication was not enforced).
|
|
66
|
+
"""
|
|
67
|
+
identity = _current_identity.get()
|
|
68
|
+
|
|
69
|
+
if identity is None or not identity.user.is_authenticated:
|
|
70
|
+
raise PermissionError("No authenticated user for this MCP request")
|
|
71
|
+
|
|
72
|
+
return identity.user
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def get_current_oauth2_token() -> Any | None:
|
|
76
|
+
"""Return the real OAuth2 access token for the current MCP request, if any."""
|
|
77
|
+
identity = _current_identity.get()
|
|
78
|
+
return identity.oauth2_token if identity else None
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""MCP server for InvenTree"""
|
|
2
|
+
|
|
3
|
+
from typing import ClassVar
|
|
4
|
+
|
|
5
|
+
from plugin import InvenTreePlugin
|
|
6
|
+
from plugin.mixins import SettingsMixin, UrlsMixin
|
|
7
|
+
|
|
8
|
+
from . import PLUGIN_VERSION
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class InvenTreeMCP(SettingsMixin, UrlsMixin, InvenTreePlugin):
|
|
12
|
+
"""InvenTreeMCP - custom InvenTree plugin."""
|
|
13
|
+
|
|
14
|
+
# Plugin metadata
|
|
15
|
+
TITLE = "InvenTree MCP"
|
|
16
|
+
NAME = "InvenTreeMCP"
|
|
17
|
+
SLUG = "inventree-mcp"
|
|
18
|
+
DESCRIPTION = "MCP server for InvenTree"
|
|
19
|
+
VERSION = PLUGIN_VERSION
|
|
20
|
+
|
|
21
|
+
# Additional project information
|
|
22
|
+
AUTHOR = "Oliver Walters"
|
|
23
|
+
WEBSITE = "https://github.com/inventree/inventree-mcp"
|
|
24
|
+
LICENSE = "MIT"
|
|
25
|
+
|
|
26
|
+
# Optionally specify supported InvenTree versions
|
|
27
|
+
# MIN_VERSION = '0.18.0'
|
|
28
|
+
# MAX_VERSION = '2.0.0'
|
|
29
|
+
|
|
30
|
+
# Plugin settings (from SettingsMixin)
|
|
31
|
+
# Ref: https://docs.inventree.org/en/latest/plugins/mixins/settings/
|
|
32
|
+
SETTINGS: ClassVar[dict] = {
|
|
33
|
+
"REQUIRE_AUTH": {
|
|
34
|
+
"name": "Require Authentication",
|
|
35
|
+
"description": "Reject unauthenticated requests to the MCP endpoint. Disable only for local testing.",
|
|
36
|
+
"validator": bool,
|
|
37
|
+
"default": True,
|
|
38
|
+
},
|
|
39
|
+
"MCP_READ_ONLY": {
|
|
40
|
+
"name": "Read Only",
|
|
41
|
+
"description": "Block all write actions via the MCP endpoint, regardless of the calling user's permissions.",
|
|
42
|
+
"validator": bool,
|
|
43
|
+
"default": True,
|
|
44
|
+
},
|
|
45
|
+
"MCP_LOG_TOOL_CALLS": {
|
|
46
|
+
"name": "Log Tool Calls",
|
|
47
|
+
"description": "Log every MCP tool call (tool name, arguments, calling user, and outcome) to the 'inventree' logger for debugging.",
|
|
48
|
+
"validator": bool,
|
|
49
|
+
"default": False,
|
|
50
|
+
},
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
# Custom URL endpoints (from UrlsMixin)
|
|
54
|
+
# Ref: https://docs.inventree.org/en/latest/plugins/mixins/urls/
|
|
55
|
+
def setup_urls(self):
|
|
56
|
+
"""Configure custom URL endpoints for this plugin."""
|
|
57
|
+
from .mcp_transport import urlpatterns
|
|
58
|
+
|
|
59
|
+
return urlpatterns
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Describe a view's optional output-expansion flags (e.g. `part_detail`).
|
|
2
|
+
|
|
3
|
+
Many InvenTree list/detail views can inline a related object's full detail
|
|
4
|
+
into the response on request (`?part_detail=true` on the stock API embeds
|
|
5
|
+
the full Part, avoiding a separate get_part call) rather than only exposing
|
|
6
|
+
its ID. These flags are declared once per view as `output_options`, an
|
|
7
|
+
`InvenTree.fields.OutputConfiguration` subclass listing `InvenTreeOutputOption`
|
|
8
|
+
instances (flag name, default, description) - the same metadata InvenTree's
|
|
9
|
+
own OpenAPI schema is generated from (see `InvenTree.schema.
|
|
10
|
+
schema_for_view_output_options`). Reading it here means this can't drift
|
|
11
|
+
from the real API the way a hand-maintained list per resource would.
|
|
12
|
+
|
|
13
|
+
Not every view declares `output_options` (flat resources with no
|
|
14
|
+
expandable relations, e.g. Company, Attachment, don't need it) -
|
|
15
|
+
describe_output_options() returns an empty dict for those rather than
|
|
16
|
+
raising, same permissive spirit as filter_introspection.py.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def describe_output_options(view_cls: type) -> dict[str, Any]:
|
|
25
|
+
"""Describe the optional output-expansion flags available on *view_cls*.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
view_cls: a list or detail view class as used in the real API
|
|
29
|
+
urlconf (e.g. stock.api.StockList or stock.api.StockDetail).
|
|
30
|
+
Both a resource's list and detail view conventionally share the
|
|
31
|
+
same `output_options` class, so either works.
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
A dict of {flag_name: {default_included, description}} - each key
|
|
35
|
+
is a boolean query parameter that can be passed directly in a
|
|
36
|
+
list/get tool's `filters` argument, e.g. filters={"part_detail": true}.
|
|
37
|
+
Empty if the view declares no `output_options`.
|
|
38
|
+
"""
|
|
39
|
+
output_config = getattr(view_cls, "output_options", None)
|
|
40
|
+
if output_config is None:
|
|
41
|
+
return {}
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
option.flag: {
|
|
45
|
+
"default_included": bool(option.default),
|
|
46
|
+
"description": option.description,
|
|
47
|
+
}
|
|
48
|
+
for option in output_config.OPTIONS
|
|
49
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""Describe a DRF list view's real filter/search/ordering options.
|
|
2
|
+
|
|
3
|
+
Mirrors schema_introspection.py's approach for output shapes: rather than
|
|
4
|
+
hand-maintain a description of what's filterable per resource (which would
|
|
5
|
+
drift as InvenTree's FilterSet classes evolve), this reads the same metadata
|
|
6
|
+
django-filter itself uses (view.filterset_class.base_filters) plus DRF's
|
|
7
|
+
search_fields/ordering_fields, and turns it into a plain dict a calling
|
|
8
|
+
agent can use directly as the `filters` argument on a list_* tool.
|
|
9
|
+
|
|
10
|
+
Some views (e.g. company.api.ContactList/AddressList) skip a full
|
|
11
|
+
filterset_class and instead use DRF's `filterset_fields = [...]` shorthand,
|
|
12
|
+
which makes django_filter's DjangoFilterBackend auto-build an exact-match
|
|
13
|
+
FilterSet at request time - the real API genuinely accepts these as query
|
|
14
|
+
params, but there's no filterset_class for us to introspect. _model_field_filters()
|
|
15
|
+
covers that case by reading the field type off the view's own model instead.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
import django_filters as df
|
|
23
|
+
from django.core.exceptions import FieldDoesNotExist
|
|
24
|
+
|
|
25
|
+
# Best-effort Django model field internal-type -> our type string, used only
|
|
26
|
+
# as a fallback for plain `filterset_fields` (no explicit FilterSet class to
|
|
27
|
+
# read a real django_filters.Filter subclass off of). Not exhaustive - falls
|
|
28
|
+
# back to "string" for anything unlisted, same permissive spirit as
|
|
29
|
+
# _filter_type_name() below.
|
|
30
|
+
_MODEL_FIELD_TYPES: dict[str, str] = {
|
|
31
|
+
"ForeignKey": "integer (id)",
|
|
32
|
+
"OneToOneField": "integer (id)",
|
|
33
|
+
"BooleanField": "boolean",
|
|
34
|
+
"IntegerField": "number",
|
|
35
|
+
"PositiveIntegerField": "number",
|
|
36
|
+
"FloatField": "number",
|
|
37
|
+
"DecimalField": "number",
|
|
38
|
+
"DateTimeField": "string (date-time)",
|
|
39
|
+
"DateField": "string (date)",
|
|
40
|
+
"TimeField": "string (time)",
|
|
41
|
+
"UUIDField": "string (uuid)",
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
# Ordered most-specific-subclass-first: e.g. ModelChoiceFilter is a
|
|
45
|
+
# ChoiceFilter subclass and must be matched before the generic entry below it.
|
|
46
|
+
_ORDERED_FILTER_TYPES: list[tuple[type, str]] = [
|
|
47
|
+
(df.BooleanFilter, "boolean"),
|
|
48
|
+
(df.ModelMultipleChoiceFilter, "array of integer (id)"),
|
|
49
|
+
(df.ModelChoiceFilter, "integer (id)"),
|
|
50
|
+
(df.MultipleChoiceFilter, "array of string (choice)"),
|
|
51
|
+
(df.ChoiceFilter, "string (choice)"),
|
|
52
|
+
(df.NumberFilter, "number"),
|
|
53
|
+
(df.DateTimeFilter, "string (date-time)"),
|
|
54
|
+
(df.DateFilter, "string (date)"),
|
|
55
|
+
(df.TimeFilter, "string (time)"),
|
|
56
|
+
(df.UUIDFilter, "string (uuid)"),
|
|
57
|
+
(df.CharFilter, "string"),
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _filter_type_name(f: df.Filter) -> str:
|
|
62
|
+
for filter_type, name in _ORDERED_FILTER_TYPES:
|
|
63
|
+
if isinstance(f, filter_type):
|
|
64
|
+
return name
|
|
65
|
+
return "string"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _filter_info(f: df.Filter) -> dict[str, Any]:
|
|
69
|
+
info: dict[str, Any] = {"type": _filter_type_name(f)}
|
|
70
|
+
|
|
71
|
+
if f.label:
|
|
72
|
+
info["label"] = str(f.label)
|
|
73
|
+
|
|
74
|
+
choices = getattr(f, "extra", {}).get("choices")
|
|
75
|
+
if choices:
|
|
76
|
+
info["choices"] = [value for value, _display in choices]
|
|
77
|
+
|
|
78
|
+
return info
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _model_field_filters(view_cls: type) -> dict[str, Any]:
|
|
82
|
+
"""Best-effort filter info for views using DRF's `filterset_fields` shorthand.
|
|
83
|
+
|
|
84
|
+
No django_filters.Filter instances exist to introspect here - only a
|
|
85
|
+
list of field names DjangoFilterBackend auto-generates exact-match
|
|
86
|
+
filters from at request time - so this reads the type straight off the
|
|
87
|
+
view's own model instead.
|
|
88
|
+
"""
|
|
89
|
+
field_names = getattr(view_cls, "filterset_fields", None) or []
|
|
90
|
+
model = getattr(getattr(view_cls, "queryset", None), "model", None)
|
|
91
|
+
if not field_names or model is None:
|
|
92
|
+
return {}
|
|
93
|
+
|
|
94
|
+
filters = {}
|
|
95
|
+
for name in field_names:
|
|
96
|
+
try:
|
|
97
|
+
field = model._meta.get_field(name)
|
|
98
|
+
except FieldDoesNotExist:
|
|
99
|
+
filters[name] = {"type": "string"}
|
|
100
|
+
continue
|
|
101
|
+
|
|
102
|
+
filters[name] = {
|
|
103
|
+
"type": _MODEL_FIELD_TYPES.get(field.get_internal_type(), "string")
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return filters
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _default_ordering_fields(view_cls: type) -> list[str]:
|
|
110
|
+
"""Best-effort reproduction of DRF's own fallback for views that don't declare `ordering_fields`.
|
|
111
|
+
|
|
112
|
+
`rest_framework.filters.OrderingFilter.get_default_valid_fields()` doesn't
|
|
113
|
+
treat a missing `ordering_fields` as "no ordering allowed" - it allows
|
|
114
|
+
ordering by any non-write-only field on the view's serializer instead.
|
|
115
|
+
Before this, `describe_filterset()` read `getattr(view_cls,
|
|
116
|
+
'ordering_fields', None) or []`, which couldn't distinguish "not declared"
|
|
117
|
+
from "declared empty" and reported zero ordering fields either way - e.g.
|
|
118
|
+
`describe_filters("bom_substitute")` claimed no ordering was possible even
|
|
119
|
+
though `ordering=part` genuinely works against the real API
|
|
120
|
+
(`BomItemSubstituteList` doesn't declare `ordering_fields`). Mirrors DRF's
|
|
121
|
+
logic rather than calling it directly: DRF's version needs a bound view
|
|
122
|
+
*instance* (`view.get_serializer_class()`), but this module only ever has
|
|
123
|
+
the view *class* to introspect with, same constraint as the rest of this
|
|
124
|
+
file.
|
|
125
|
+
"""
|
|
126
|
+
serializer_class = getattr(view_cls, "serializer_class", None)
|
|
127
|
+
if serializer_class is None:
|
|
128
|
+
return []
|
|
129
|
+
|
|
130
|
+
try:
|
|
131
|
+
fields = serializer_class().fields
|
|
132
|
+
except (TypeError, AssertionError):
|
|
133
|
+
# TypeError: constructor needs args this bare instantiation can't
|
|
134
|
+
# supply. AssertionError: DRF's own internal checks (e.g. a
|
|
135
|
+
# ModelSerializer subclass missing Meta.model). Fail soft to "no
|
|
136
|
+
# ordering fields reported" rather than raising out of a pure
|
|
137
|
+
# introspection helper.
|
|
138
|
+
return []
|
|
139
|
+
|
|
140
|
+
ordering_fields: list[str] = []
|
|
141
|
+
for field in fields.values():
|
|
142
|
+
if getattr(field, "write_only", False) or field.source == "*":
|
|
143
|
+
continue
|
|
144
|
+
source = field.source.replace(".", "__")
|
|
145
|
+
if source not in ordering_fields:
|
|
146
|
+
ordering_fields.append(source)
|
|
147
|
+
|
|
148
|
+
return ordering_fields
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def describe_filterset(view_cls: type) -> dict[str, Any]:
|
|
152
|
+
"""Describe the search/filter/ordering options available on *view_cls*.
|
|
153
|
+
|
|
154
|
+
Args:
|
|
155
|
+
view_cls: a list view class as used in the real API urlconf (e.g.
|
|
156
|
+
part.api.PartList).
|
|
157
|
+
|
|
158
|
+
Returns:
|
|
159
|
+
A dict with:
|
|
160
|
+
- search_fields: fields matched by a free-text `search` query param.
|
|
161
|
+
- ordering_fields: fields usable as the corresponding list tool's
|
|
162
|
+
`ordering` argument (prefix with '-' for descending). If the view
|
|
163
|
+
doesn't declare `ordering_fields` at all, this reports DRF's own
|
|
164
|
+
fallback (any readable serializer field) rather than an empty list
|
|
165
|
+
- see _default_ordering_fields().
|
|
166
|
+
- filters: {name: {type, label, choices}} - each key is usable
|
|
167
|
+
directly as a query parameter / filters dict entry.
|
|
168
|
+
"""
|
|
169
|
+
filterset_class = getattr(view_cls, "filterset_class", None)
|
|
170
|
+
|
|
171
|
+
if filterset_class is not None:
|
|
172
|
+
filters = {
|
|
173
|
+
name: _filter_info(f) for name, f in filterset_class.base_filters.items()
|
|
174
|
+
}
|
|
175
|
+
else:
|
|
176
|
+
filters = _model_field_filters(view_cls)
|
|
177
|
+
|
|
178
|
+
declared_ordering = getattr(view_cls, "ordering_fields", None)
|
|
179
|
+
ordering_fields = (
|
|
180
|
+
list(declared_ordering)
|
|
181
|
+
if declared_ordering is not None
|
|
182
|
+
else _default_ordering_fields(view_cls)
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
return {
|
|
186
|
+
"search_fields": list(getattr(view_cls, "search_fields", None) or []),
|
|
187
|
+
"ordering_fields": ordering_fields,
|
|
188
|
+
"filters": filters,
|
|
189
|
+
}
|