Publish curated Unraid-AppOps-Gateway source from 28831bd43867347a132f1c2b0f388632b9cb561e
This commit is contained in:
1 parent
41fd48f0bc
commit
2d50064a8f
4 files changed
+92
-7
No files matched your search
@@ -1,3 +1,3 @@
|
|||||||
# Public source export
|
# 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.
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"schemaVersion": 1,
|
"schemaVersion": 1,
|
||||||
"sourceRevision": "6d175c3333218e2b580a310496fed6c407f28114",
|
"sourceRevision": "28831bd43867347a132f1c2b0f388632b9cb561e",
|
||||||
"files": [
|
"files": [
|
||||||
{
|
{
|
||||||
"path": ".dockerignore",
|
"path": ".dockerignore",
|
||||||
@@ -404,8 +404,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "backend/app/mcp_server.py",
|
"path": "backend/app/mcp_server.py",
|
||||||
"sha256": "598e7f7a5020d92becfb143d31dbd096f16bf79ae73b26e6ecae710661f73ca1",
|
"sha256": "065bc0c1f9779f0df30cb6d19eee129b0cf3c4b66a190687e9bfc682c239c94a",
|
||||||
"bytes": 50519
|
"bytes": 51192
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "backend/app/runner_statistics_mcp.py",
|
"path": "backend/app/runner_statistics_mcp.py",
|
||||||
@@ -1284,8 +1284,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "backend/tests/test_mcp_rag_n8n_integrations.py",
|
"path": "backend/tests/test_mcp_rag_n8n_integrations.py",
|
||||||
"sha256": "1d72e95529863d3263a178d71d4c7729c8b624b13e734460fb9303a3f6f7416d",
|
"sha256": "587ff5dac2861f47224f0ec174ad0ef2472980f2ba41d28d7f2d72eb4015abd7",
|
||||||
"bytes": 54507
|
"bytes": 57461
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "backend/tests/test_media_codec_dashboard_deployment_reconciliation.py",
|
"path": "backend/tests/test_media_codec_dashboard_deployment_reconciliation.py",
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from time import perf_counter
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from mcp.server import MCPServer
|
from mcp.server import MCPServer
|
||||||
|
from mcp.server.mcpserver.exceptions import ToolError
|
||||||
from mcp.server.transport_security import TransportSecuritySettings
|
from mcp.server.transport_security import TransportSecuritySettings
|
||||||
from mcp.types import ToolAnnotations
|
from mcp.types import ToolAnnotations
|
||||||
from pydantic import StrictBool, ValidationError
|
from pydantic import StrictBool, ValidationError
|
||||||
@@ -215,7 +216,19 @@ def run_read_only_canary() -> dict[str, Any]:
|
|||||||
return dict(_CANARY_STATUS)
|
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",
|
"Unraid AppOps Gateway",
|
||||||
version=APP_VERSION,
|
version=APP_VERSION,
|
||||||
instructions=(
|
instructions=(
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import json
|
|||||||
from concurrent.futures import ThreadPoolExecutor
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from pydantic import StrictBool
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
from mcp import Client
|
from mcp import Client
|
||||||
from sqlmodel import Session, SQLModel, create_engine, select
|
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())
|
asyncio.run(exercise())
|
||||||
assert mcp_server._MCP_EXECUTION_LOCK.locked() is False
|
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]
|
||||||
Reference in new issue
Block a user