|
10 | 10 | from __future__ import annotations |
11 | 11 |
|
12 | 12 | import datetime |
| 13 | +import importlib |
13 | 14 | import json |
14 | 15 | import sys |
15 | 16 | import unittest |
|
27 | 28 | from django.contrib.auth.models import AnonymousUser |
28 | 29 | from django.contrib.contenttypes.models import ContentType |
29 | 30 | from django.test import Client, override_settings |
| 31 | +from django.urls import reverse |
30 | 32 | from django.utils import timezone |
31 | 33 | from InvenTree.api_version import INVENTREE_API_VERSION |
32 | 34 | from InvenTree.unit_test import InvenTreeTestCase |
33 | 35 | from mcp.server.mcpserver.exceptions import ToolError |
34 | 36 | from mcp.types import CallToolResult |
| 37 | +from mcp_types.version import SUPPORTED_PROTOCOL_VERSIONS |
35 | 38 | from oauth2_provider.models import AccessToken, Application |
36 | 39 | from order.models import ( |
37 | 40 | PurchaseOrder, |
|
55 | 58 | ) |
56 | 59 | from users.models import ApiToken |
57 | 60 |
|
58 | | -from . import context, tool_visibility, view_resolution |
| 61 | +from . import PLUGIN_VERSION, context, tool_visibility, view_resolution |
59 | 62 | from .filter_introspection import _default_ordering_fields |
60 | 63 | from .mcp_server import mcp |
61 | 64 | from .proxy import call_view |
62 | 65 | from .schema_introspection import paginated_schema, serializer_schema |
| 66 | +from .server_card import SERVER_CARD_SCHEMA, build_server_card |
63 | 67 | from .settings import get_plugin_setting |
64 | 68 | from .tools import discovery |
65 | 69 | 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): |
2368 | 2372 | } |
2369 | 2373 |
|
2370 | 2374 | 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