From 2d50064a8f7d59339c85ea80a56ddfe4b27cb437 Mon Sep 17 00:00:00 2001 From: Public Source Publisher Date: Fri, 9 Oct 2026 13:09:48 +0000 Subject: [PATCH] Publish curated Unraid-AppOps-Gateway source from 28831bd43867347a132f1c2b0f388632b9cb561e --- PUBLIC_SOURCE_EXPORT.md | 2 +- PUBLIC_SOURCE_MANIFEST.json | 10 +-- backend/app/mcp_server.py | 15 +++- .../tests/test_mcp_rag_n8n_integrations.py | 72 +++++++++++++++++++ 4 files changed, 92 insertions(+), 7 deletions(-) diff --git a/PUBLIC_SOURCE_EXPORT.md b/PUBLIC_SOURCE_EXPORT.md index e5058ec..eb98984 100644 --- a/PUBLIC_SOURCE_EXPORT.md +++ b/PUBLIC_SOURCE_EXPORT.md @@ -1,3 +1,3 @@ # Public source export -This source snapshot was generated from the private canonical repository at revision `6d175c3333218e2b580a310496fed6c407f28114`. It contains no private Git history or operational evidence. Changes are published from the canonical repository. +This source snapshot was generated from the private canonical repository at revision `28831bd43867347a132f1c2b0f388632b9cb561e`. It contains no private Git history or operational evidence. Changes are published from the canonical repository. diff --git a/PUBLIC_SOURCE_MANIFEST.json b/PUBLIC_SOURCE_MANIFEST.json index 26eb448..f47ac97 100644 --- a/PUBLIC_SOURCE_MANIFEST.json +++ b/PUBLIC_SOURCE_MANIFEST.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "sourceRevision": "6d175c3333218e2b580a310496fed6c407f28114", + "sourceRevision": "28831bd43867347a132f1c2b0f388632b9cb561e", "files": [ { "path": ".dockerignore", @@ -404,8 +404,8 @@ }, { "path": "backend/app/mcp_server.py", - "sha256": "598e7f7a5020d92becfb143d31dbd096f16bf79ae73b26e6ecae710661f73ca1", - "bytes": 50519 + "sha256": "065bc0c1f9779f0df30cb6d19eee129b0cf3c4b66a190687e9bfc682c239c94a", + "bytes": 51192 }, { "path": "backend/app/runner_statistics_mcp.py", @@ -1284,8 +1284,8 @@ }, { "path": "backend/tests/test_mcp_rag_n8n_integrations.py", - "sha256": "1d72e95529863d3263a178d71d4c7729c8b624b13e734460fb9303a3f6f7416d", - "bytes": 54507 + "sha256": "587ff5dac2861f47224f0ec174ad0ef2472980f2ba41d28d7f2d72eb4015abd7", + "bytes": 57461 }, { "path": "backend/tests/test_media_codec_dashboard_deployment_reconciliation.py", diff --git a/backend/app/mcp_server.py b/backend/app/mcp_server.py index 384fc4d..f7a5e44 100644 --- a/backend/app/mcp_server.py +++ b/backend/app/mcp_server.py @@ -13,6 +13,7 @@ from time import perf_counter from typing import Any from mcp.server import MCPServer +from mcp.server.mcpserver.exceptions import ToolError from mcp.server.transport_security import TransportSecuritySettings from mcp.types import ToolAnnotations from pydantic import StrictBool, ValidationError @@ -215,7 +216,19 @@ def run_read_only_canary() -> dict[str, Any]: return dict(_CANARY_STATUS) -appops_mcp = MCPServer( +class _InputRedactingMCPServer(MCPServer): + async def call_tool(self, name: str, arguments: dict[str, Any], context: Any = None) -> Any: + try: + return await super().call_tool(name, arguments, context) + except ToolError as error: + # The SDK wraps argument validation before calling our typed handlers. + # Do not let its input-bearing error text or cause reach the wire/logger. + if type(error) is ToolError and isinstance(error.__cause__, ValidationError): + raise ToolError("invalid_tool_arguments") from None + raise + + +appops_mcp = _InputRedactingMCPServer( "Unraid AppOps Gateway", version=APP_VERSION, instructions=( diff --git a/backend/tests/test_mcp_rag_n8n_integrations.py b/backend/tests/test_mcp_rag_n8n_integrations.py index 5765e46..a9c8e90 100644 --- a/backend/tests/test_mcp_rag_n8n_integrations.py +++ b/backend/tests/test_mcp_rag_n8n_integrations.py @@ -6,6 +6,7 @@ import json from concurrent.futures import ThreadPoolExecutor import pytest +from pydantic import StrictBool from fastapi.testclient import TestClient from mcp import Client from sqlmodel import Session, SQLModel, create_engine, select @@ -1410,3 +1411,74 @@ def test_mcp_typed_schema_rejection_never_echoes_opaque_input( asyncio.run(exercise()) assert mcp_server._MCP_EXECUTION_LOCK.locked() is False + + +@pytest.mark.parametrize(("name", "argument", "shape"), [ + (name, argument, shape) + for name, argument in [ + ("enrollment.preflight", "plan"), + ("target.enroll", "plan"), + ("deployment.preflight", "plan"), + ("deployment.apply", "plan"), + ("deployment.rollback", "plan"), + ("forge.bootstrap.apply", "profile"), + ] + for shape in ["string", "list", "idempotency"] + if shape != "idempotency" or name != "enrollment.preflight" +]) +def test_mcp_sdk_argument_rejection_never_echoes_input_or_logs( + integration_client, monkeypatch, caplog, name, argument, shape, +): + from backend.app import mcp_server + canary = "syntheticSdkOpaqueInput_6Q8XZ" + arguments = {argument: [canary] if shape == "list" else canary} + if shape == "idempotency": + arguments = {argument: {}, "idempotencyKey": [canary]} + elif name != "enrollment.preflight": + arguments["idempotencyKey"] = "schema-redaction-fixture" + + def forbidden_service(*_args, **_kwargs): + pytest.fail("invalid SDK arguments must not reach execution services") + + monkeypatch.setattr(mcp_server, "EnrollmentPlane", forbidden_service) + monkeypatch.setattr(mcp_server, "DeploymentControlPlane", forbidden_service) + caplog.set_level("INFO") + + async def exercise(): + async with Client(mcp_server.appops_mcp) as client: + result = await client.call_tool(name, arguments) + assert result.is_error is True + assert [item.text for item in result.content] == ["invalid_tool_arguments"] + assert canary not in json.dumps(result.model_dump(mode="json")) + + asyncio.run(exercise()) + assert canary not in caplog.text + assert mcp_server._MCP_EXECUTION_LOCK.locked() is False + + +def test_mcp_sdk_redaction_preserves_schema_valid_calls_and_domain_denials(): + from backend.app.mcp_server import _InputRedactingMCPServer + from mcp.server.mcpserver.exceptions import ToolError + + server = _InputRedactingMCPServer("redaction-compatibility-fixture") + calls = [] + + @server.tool(name="governed.fixture") + def governed(allowed: StrictBool) -> dict[str, str]: + calls.append(allowed) + if not allowed: + raise ToolError("policy_denied") + return {"status": "allowed"} + + async def exercise(): + async with Client(server) as client: + listing = await client.list_tools() + assert listing.tools[0].input_schema["properties"]["allowed"]["type"] == "boolean" + accepted = await client.call_tool("governed.fixture", {"allowed": True}) + assert accepted.is_error is False + assert accepted.structured_content == {"status": "allowed"} + denied = await client.call_tool("governed.fixture", {"allowed": False}) + assert denied.is_error is True + assert "policy_denied" in denied.content[0].text + asyncio.run(exercise()) + assert calls == [True, False]