mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-09-13 19:52:20 +02:00
* fix(mcp): reject malformed Args on Add MCP Server instead of silently defaulting to [] * test(mcp): pass every Form param add_server reads past args validation CI's pytest run showed test_add_server_still_accepts_valid_json_args and test_add_server_still_defaults_empty_args_to_empty_list failing with TypeError: the JSON object must be str, bytes or bytearray, not Form. Calling the endpoint function directly bypasses FastAPI's dependency resolution, so an unpassed Form(...) parameter (url, oauth_file, oauth_config) arrives as the Form marker object itself rather than its declared default, and add_server's later `if oauth_file:` check reads that marker as truthy. The malformed-args test never hit this because it raises before reaching that code. Not a production bug: a real HTTP request resolves these through FastAPI before add_server ever runs. * fix(mcp): reject non-list args and surface the new 400 in the Admin panel o3LL's review on #6215 found two gaps in the args validation this PR adds: the Admin panel posts to the same /api/mcp/servers endpoint but never validates Args client-side, so the new 400 falls into the generic failure branch and shows "Added but connection failed: unknown". Mirror the same JSON.parse guard settings.js already has. Also add an isinstance(list) check next to the existing JSON parse, since valid-but-wrong-shaped JSON (args=5) reaches StdioServerParameters(args=5) and 500s in the error formatter. Pre-existing on dev, same validation site this PR already touches. * fix(admin): surface the server's 400 detail instead of a generic connection-failed message The Admin add-server handler read needs_oauth/connected/error but never res.ok, so a request rejected by the isinstance(list) check added for #6211 (args=5, a valid-JSON-but-non-list value the client-side JSON.parse guard cannot catch) fell into the same-shape else branch as a successful add whose connection attempt failed, and the form fields were cleared as if the server had accepted it.
148 lines
5.2 KiB
Python
148 lines
5.2 KiB
Python
"""Regression test for issue #6211: a malformed Args value on the "Add MCP
|
|
Server" form must not be silently discarded into an empty argv.
|
|
|
|
routes/mcp/mcp_routes.py's add_server() wrapped json.loads(args) in a bare
|
|
except that fell back to `[]`, so a non-JSON Args value registered the
|
|
server as "Connected" while forwarding no arguments to the spawned stdio
|
|
subprocess at all, with no error surfaced anywhere.
|
|
"""
|
|
import asyncio
|
|
import json
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
import pytest
|
|
from fastapi import HTTPException
|
|
|
|
from routes.mcp import mcp_routes
|
|
|
|
|
|
class _FakeSession:
|
|
"""Stands in for core.database.SessionLocal(); add_server only adds+commits."""
|
|
|
|
def __init__(self):
|
|
self.added = []
|
|
|
|
def add(self, obj):
|
|
self.added.append(obj)
|
|
|
|
def commit(self):
|
|
pass
|
|
|
|
def close(self):
|
|
pass
|
|
|
|
|
|
def _add_server(monkeypatch):
|
|
"""Register add_server on the shared module-level router and return the
|
|
freshly-added route's raw endpoint function, bypassing HTTP/Form parsing
|
|
(require_admin is the only other thing the function touches via `request`).
|
|
|
|
Callers must pass every Form(...) parameter add_server reads past the args
|
|
check (url, oauth_file, oauth_config): calling the endpoint directly skips
|
|
FastAPI's dependency resolution, so an omitted one arrives as the Form
|
|
marker object itself rather than its declared default, and later code
|
|
(e.g. `if oauth_file:`) reads that marker as truthy.
|
|
"""
|
|
monkeypatch.setattr(mcp_routes, "require_admin", lambda request: None)
|
|
manager = MagicMock()
|
|
manager.connect_server = AsyncMock(return_value=True)
|
|
manager.get_server_status = MagicMock(return_value={"status": "connected", "tool_count": 1})
|
|
router = mcp_routes.setup_mcp_routes(manager)
|
|
# setup_mcp_routes appends new APIRoute objects to the shared router on
|
|
# every call, so take the LAST "add_server" route: the one just registered
|
|
# with our fake manager, not an earlier registration from importing app.py.
|
|
route = [r for r in router.routes if getattr(r, "name", None) == "add_server"][-1]
|
|
return route.endpoint, manager
|
|
|
|
|
|
def test_add_server_rejects_malformed_args_instead_of_defaulting(monkeypatch):
|
|
add_server, manager = _add_server(monkeypatch)
|
|
monkeypatch.setattr(mcp_routes, "SessionLocal", lambda: (_ for _ in ()).throw(
|
|
AssertionError("must not reach the DB when args is rejected")))
|
|
|
|
with pytest.raises(HTTPException) as exc:
|
|
asyncio.run(add_server(
|
|
request=None,
|
|
name="filesystem",
|
|
transport="stdio",
|
|
command="mcp-server-filesystem",
|
|
args="/app/data/jarvis-files", # the exact value from issue #6211
|
|
env="{}",
|
|
url=None,
|
|
oauth_file=None,
|
|
oauth_config=None,
|
|
))
|
|
|
|
assert exc.value.status_code == 400
|
|
manager.connect_server.assert_not_called()
|
|
|
|
|
|
def test_add_server_still_accepts_valid_json_args(monkeypatch):
|
|
add_server, manager = _add_server(monkeypatch)
|
|
fake_session = _FakeSession()
|
|
monkeypatch.setattr(mcp_routes, "SessionLocal", lambda: fake_session)
|
|
|
|
result = asyncio.run(add_server(
|
|
request=None,
|
|
name="filesystem",
|
|
transport="stdio",
|
|
command="mcp-server-filesystem",
|
|
args=json.dumps(["/app/data/jarvis-files"]),
|
|
env="{}",
|
|
url=None,
|
|
oauth_file=None,
|
|
oauth_config=None,
|
|
))
|
|
|
|
assert result["connected"] is True
|
|
manager.connect_server.assert_awaited_once()
|
|
assert manager.connect_server.call_args.kwargs["args"] == ["/app/data/jarvis-files"]
|
|
assert fake_session.added[0].args == json.dumps(["/app/data/jarvis-files"])
|
|
|
|
|
|
def test_add_server_rejects_valid_json_args_that_is_not_a_list(monkeypatch):
|
|
"""Valid JSON that is not a list (e.g. args=5) must not reach
|
|
StdioServerParameters(args=5), which raises an unhandled TypeError when
|
|
the error formatter later does " ".join([command, *args])."""
|
|
add_server, manager = _add_server(monkeypatch)
|
|
monkeypatch.setattr(mcp_routes, "SessionLocal", lambda: (_ for _ in ()).throw(
|
|
AssertionError("must not reach the DB when args has the wrong shape")))
|
|
|
|
with pytest.raises(HTTPException) as exc:
|
|
asyncio.run(add_server(
|
|
request=None,
|
|
name="filesystem",
|
|
transport="stdio",
|
|
command="mcp-server-filesystem",
|
|
args="5",
|
|
env="{}",
|
|
url=None,
|
|
oauth_file=None,
|
|
oauth_config=None,
|
|
))
|
|
|
|
assert exc.value.status_code == 400
|
|
manager.connect_server.assert_not_called()
|
|
|
|
|
|
def test_add_server_still_defaults_empty_args_to_empty_list(monkeypatch):
|
|
"""No behavior change for the common case of an empty Args field."""
|
|
add_server, manager = _add_server(monkeypatch)
|
|
fake_session = _FakeSession()
|
|
monkeypatch.setattr(mcp_routes, "SessionLocal", lambda: fake_session)
|
|
|
|
result = asyncio.run(add_server(
|
|
request=None,
|
|
name="no-args-server",
|
|
transport="stdio",
|
|
command="some-command",
|
|
args="",
|
|
env="{}",
|
|
url=None,
|
|
oauth_file=None,
|
|
oauth_config=None,
|
|
))
|
|
|
|
assert result["connected"] is True
|
|
assert manager.connect_server.call_args.kwargs["args"] == []
|