Skip to content

Commit da89b4d

Browse files
Merge pull request #46 from inventree/well-known
add well-known URL
2 parents 5eab7df + d989b16 commit da89b4d

5 files changed

Lines changed: 228 additions & 4 deletions

File tree

AGENTS.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,10 @@ duplicate the check per-tool or bypass `call_view()`.
3535
- `expand_introspection.py` - derives optional output-expansion flags (e.g. `part_detail`) from a
3636
view's real `output_options`.
3737
- `core.py` - the `InvenTreePlugin` subclass, `REQUIRE_AUTH` and `MCP_READ_ONLY` settings.
38+
- `server_card.py` - serves an MCP Server Card (SEP-2127) at `plugin/inventree-mcp/server-card/`
39+
for discovery, advertised under `/.well-known/` via `core.py`'s `get_well_known_urls()`.
40+
- `_well_known_compat.py` - `WellKnownMixin` import, falling back to a blank mixin on InvenTree
41+
versions predating `inventree/InvenTree#12698` (not yet in a "stable" release as of writing).
3842

3943
## Testing
4044

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
"""Compat shim for InvenTree versions that don't yet provide WellKnownMixin.
2+
3+
WellKnownMixin (letting a plugin advertise entries under /.well-known/) was
4+
added in inventree/InvenTree#12698, merged to InvenTree's master branch but
5+
not yet present in a "stable" release. InvenTreeMCP must still import and
6+
load cleanly on older InvenTree instances, so this falls back to a blank
7+
mixin that simply advertises nothing when the real one isn't available.
8+
"""
9+
10+
try:
11+
from plugin.mixins import WellKnownMixin
12+
except ImportError: # pragma: no cover - only hit on InvenTree < #12698
13+
14+
class WellKnownMixin:
15+
"""Blank fallback used when the running InvenTree doesn't provide WellKnownMixin."""
16+
17+
def get_well_known_urls(self, request=None):
18+
"""No well-known entries to advertise without native support."""
19+
return []
20+
21+
22+
__all__ = ["WellKnownMixin"]

inventree_mcp/core.py

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,15 @@
22

33
from typing import ClassVar
44

5+
from django.urls import reverse_lazy
56
from plugin import InvenTreePlugin
67
from plugin.mixins import SettingsMixin, UrlsMixin
78

89
from . import PLUGIN_VERSION
10+
from ._well_known_compat import WellKnownMixin
911

1012

11-
class InvenTreeMCP(SettingsMixin, UrlsMixin, InvenTreePlugin):
13+
class InvenTreeMCP(WellKnownMixin, SettingsMixin, UrlsMixin, InvenTreePlugin):
1214
"""InvenTreeMCP - custom InvenTree plugin."""
1315

1416
# Plugin metadata
@@ -53,6 +55,13 @@ class InvenTreeMCP(SettingsMixin, UrlsMixin, InvenTreePlugin):
5355
# Ref: https://docs.inventree.org/en/latest/plugins/mixins/urls/
5456
def setup_urls(self):
5557
"""Configure custom URL endpoints for this plugin."""
56-
from .mcp_transport import urlpatterns
58+
from .mcp_transport import urlpatterns as mcp_urlpatterns
59+
from .server_card import urlpatterns as server_card_urlpatterns
5760

58-
return urlpatterns
61+
return mcp_urlpatterns + server_card_urlpatterns
62+
63+
# Well-known URLs (from WellKnownMixin, if the running InvenTree provides it)
64+
# Ref: https://github.com/inventree/inventree-mcp/issues/33
65+
def get_well_known_urls(self, request=None):
66+
"""Advertise this plugin's MCP Server Card under /.well-known/."""
67+
return [("mcp-server-card", reverse_lazy(f"plugin:{self.slug}:server-card"))]

inventree_mcp/server_card.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
"""Serve an MCP Server Card for discoverability.
2+
3+
Implements SEP-2127 (https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2127),
4+
which lets an MCP client discover this server's remote endpoint and supported
5+
protocol versions ahead of connecting, without guessing or hardcoding a URL.
6+
7+
The card deliberately omits tools/resources/prompts - those are dynamic
8+
(vary by caller's role/OAuth2 scope, see tool_visibility.py) and already
9+
discoverable at runtime via the real MCP `tools/list` call.
10+
11+
This view is registered as a plain plugin URL regardless of InvenTree
12+
version (see core.py's setup_urls()), so it's always directly reachable.
13+
It's only *advertised* under /.well-known/ when the running InvenTree
14+
provides WellKnownMixin - see _well_known_compat.py.
15+
"""
16+
17+
from __future__ import annotations
18+
19+
from django.http import HttpRequest, JsonResponse
20+
from django.urls import path, reverse
21+
from InvenTree.permissions import auth_exempt
22+
from mcp_types.version import SUPPORTED_PROTOCOL_VERSIONS
23+
24+
from . import PLUGIN_VERSION
25+
26+
# Must be exactly this URL per SEP-2127.
27+
SERVER_CARD_SCHEMA = (
28+
"https://static.modelcontextprotocol.io/schemas/v1/server-card.schema.json"
29+
)
30+
31+
32+
def build_server_card(request: HttpRequest) -> dict:
33+
"""Build this plugin's MCP Server Card document."""
34+
mcp_url = request.build_absolute_uri(reverse("plugin:inventree-mcp:mcp-endpoint"))
35+
36+
return {
37+
"$schema": SERVER_CARD_SCHEMA,
38+
"name": "io.github.inventree/inventree-mcp",
39+
"version": PLUGIN_VERSION,
40+
"title": "InvenTree MCP",
41+
"description": "MCP server for InvenTree",
42+
"websiteUrl": "https://github.com/inventree/inventree-mcp",
43+
"repository": {
44+
"url": "https://github.com/inventree/inventree-mcp",
45+
"source": "github",
46+
},
47+
"remotes": [
48+
{
49+
"type": "streamable-http",
50+
"url": mcp_url,
51+
"supportedProtocolVersions": list(SUPPORTED_PROTOCOL_VERSIONS),
52+
}
53+
],
54+
}
55+
56+
57+
@auth_exempt
58+
def view_server_card(request: HttpRequest) -> JsonResponse:
59+
"""Return this plugin's MCP Server Card as JSON.
60+
61+
Deliberately unauthenticated, unlike the real MCP endpoint - this is
62+
discovery metadata only (no InvenTree data), matching the passkey-endpoints
63+
well-known view InvenTree ships built-in (core_wellknown.py).
64+
"""
65+
return JsonResponse(build_server_card(request))
66+
67+
68+
urlpatterns = [path("server-card/", view_server_card, name="server-card")]

inventree_mcp/test_mcp.py

Lines changed: 122 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from __future__ import annotations
1111

1212
import datetime
13+
import importlib
1314
import json
1415
import sys
1516
import unittest
@@ -27,11 +28,13 @@
2728
from django.contrib.auth.models import AnonymousUser
2829
from django.contrib.contenttypes.models import ContentType
2930
from django.test import Client, override_settings
31+
from django.urls import reverse
3032
from django.utils import timezone
3133
from InvenTree.api_version import INVENTREE_API_VERSION
3234
from InvenTree.unit_test import InvenTreeTestCase
3335
from mcp.server.mcpserver.exceptions import ToolError
3436
from mcp.types import CallToolResult
37+
from mcp_types.version import SUPPORTED_PROTOCOL_VERSIONS
3538
from oauth2_provider.models import AccessToken, Application
3639
from order.models import (
3740
PurchaseOrder,
@@ -55,11 +58,12 @@
5558
)
5659
from users.models import ApiToken
5760

58-
from . import context, tool_visibility, view_resolution
61+
from . import PLUGIN_VERSION, context, tool_visibility, view_resolution
5962
from .filter_introspection import _default_ordering_fields
6063
from .mcp_server import mcp
6164
from .proxy import call_view
6265
from .schema_introspection import paginated_schema, serializer_schema
66+
from .server_card import SERVER_CARD_SCHEMA, build_server_card
6367
from .settings import get_plugin_setting
6468
from .tools import discovery
6569
from .tools._common import DEFAULT_LIMIT, MAX_LIMIT, build_query_params, clamp_limit
@@ -2368,3 +2372,120 @@ async def test_every_list_tool_defaults_limit_to_100(self):
23682372
}
23692373

23702374
self.assertEqual(wrong_defaults, {})
2375+
2376+
2377+
@override_settings(PLUGIN_TESTING_SETUP=True)
2378+
class ServerCardTest(InvenTreeTestCase):
2379+
"""Regression tests for the MCP Server Card well-known endpoint (server_card.py).
2380+
2381+
Implements SEP-2127 (https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2127)
2382+
2383+
Tests that the card is reachable and correctly shaped, and - separately, since
2384+
the two are independent failure modes - that it's actually wired into
2385+
WellKnownMixin (core.py's get_well_known_urls())
2386+
"""
2387+
2388+
URL = "/plugin/inventree-mcp/server-card/"
2389+
2390+
@classmethod
2391+
def setUpTestData(cls):
2392+
super().setUpTestData()
2393+
2394+
registry.reload_plugins(full_reload=True, collect=True)
2395+
registry.set_plugin_state("inventree-mcp", True)
2396+
2397+
def test_server_card_is_reachable_without_authentication(self):
2398+
"""Discovery metadata only, no InvenTree data - unlike the real MCP
2399+
endpoint (see MCPTransportTest.test_unauthenticated_request_rejected_by_default),
2400+
this must not require a credential.
2401+
"""
2402+
response = Client().get(self.URL)
2403+
2404+
self.assertEqual(response.status_code, 200)
2405+
self.assertEqual(response["Content-Type"], "application/json")
2406+
2407+
def test_server_card_matches_the_sep_2127_shape(self):
2408+
response = Client().get(self.URL)
2409+
card = response.json()
2410+
2411+
self.assertEqual(card["$schema"], SERVER_CARD_SCHEMA)
2412+
# Reverse-DNS namespace + "/" + server name, per the schema's pattern.
2413+
self.assertRegex(card["name"], r"^[a-zA-Z0-9.-]+/[a-zA-Z0-9._-]+$")
2414+
self.assertEqual(card["version"], PLUGIN_VERSION)
2415+
2416+
[remote] = card["remotes"]
2417+
self.assertEqual(remote["type"], "streamable-http")
2418+
self.assertTrue(remote["url"].endswith("/plugin/inventree-mcp/mcp/"))
2419+
self.assertEqual(
2420+
remote["supportedProtocolVersions"], list(SUPPORTED_PROTOCOL_VERSIONS)
2421+
)
2422+
2423+
def test_build_server_card_resolves_a_real_absolute_mcp_url(self):
2424+
"""Direct unit test of build_server_card() - the request-scoped absolute
2425+
URL building is the one part the HTTP-level tests above don't isolate.
2426+
"""
2427+
response = Client().get(self.URL)
2428+
card = build_server_card(response.wsgi_request)
2429+
2430+
self.assertTrue(card["remotes"][0]["url"].startswith("http"))
2431+
self.assertIn("/plugin/inventree-mcp/mcp/", card["remotes"][0]["url"])
2432+
2433+
@override_settings(
2434+
SITE_URL="http://testserver", CSRF_TRUSTED_ORIGINS=["http://testserver"]
2435+
)
2436+
def test_server_card_is_discoverable_via_the_well_known_index(self):
2437+
"""End-to-end regression test for InvenTreeMCP.get_well_known_urls() -
2438+
proves the card is reachable via InvenTree's aggregated /.well-known/
2439+
index (plugin/urls.py's wellknownindexview), the same mechanism the
2440+
built-in InvenTreeWellKnown plugin uses for passkey-endpoints.
2441+
2442+
The SITE_URL/CSRF_TRUSTED_ORIGINS override matches
2443+
InvenTreeWellKnownTest.test_well_known_urls in InvenTree core -
2444+
wellknownindexview's request.build_absolute_uri() rejects an
2445+
untrusted host otherwise.
2446+
"""
2447+
2448+
import django.urls.exceptions
2449+
2450+
try:
2451+
response = Client().get(reverse("well-known:index"))
2452+
except django.urls.exceptions.NoReverseMatch:
2453+
# Exit early, this version of the InvenTree server does not have the well-known index.
2454+
return
2455+
2456+
self.assertEqual(response.status_code, 200)
2457+
data = response.json()
2458+
self.assertIn("mcp-server-card", data["well_known_urls"])
2459+
self.assertTrue(data["well_known_urls"]["mcp-server-card"].endswith(self.URL))
2460+
2461+
2462+
class WellKnownCompatTest(unittest.TestCase):
2463+
"""Regression test for _well_known_compat.py's WellKnownMixin fallback.
2464+
2465+
WellKnownMixin was added in inventree/InvenTree#12698 (merged to master,
2466+
not yet in a "stable" release as of writing) - InvenTreeMCP must still
2467+
import and load cleanly on InvenTree versions that don't provide it.
2468+
Simulates that by deleting the real mixin from plugin.mixins and
2469+
reloading the compat shim, rather than actually downgrading InvenTree.
2470+
"""
2471+
2472+
def test_falls_back_to_a_blank_mixin_when_plugin_mixins_lacks_it(self):
2473+
import plugin.mixins as plugin_mixins
2474+
2475+
from . import _well_known_compat
2476+
2477+
try:
2478+
real_mixin = plugin_mixins.WellKnownMixin
2479+
except AttributeError:
2480+
# Exit early - the WellKnownMixin does not exist in this version of plugin.mixins
2481+
return
2482+
2483+
del plugin_mixins.WellKnownMixin
2484+
try:
2485+
reloaded = importlib.reload(_well_known_compat)
2486+
2487+
self.assertIsNot(reloaded.WellKnownMixin, real_mixin)
2488+
self.assertEqual(reloaded.WellKnownMixin().get_well_known_urls(), [])
2489+
finally:
2490+
plugin_mixins.WellKnownMixin = real_mixin
2491+
importlib.reload(_well_known_compat)

0 commit comments

Comments
 (0)