Files
DocsGPT/tests/api/user/test_tools_access.py
arc53-machine 3cab8af753 Keep the removed-connection note server-owned
Only removing a connection notes it on a kept tool. A config save through
update_tool or update_tool_config carries the stored note and ignores the
client's copy, so it can neither fake nor clear it; creating a tool, an
MCP server save and an agent import start without one. The note is now
written even for a connection with no catalog key, and a kept tool its
owner gave credentials of its own since runs again.
2026-09-29 18:12:54 +01:00

1032 lines
53 KiB
Python

"""Roles on tools: listing payload, write actions, secrets, chat preferences, MCP save."""
from __future__ import annotations
import uuid
from contextlib import contextmanager
from unittest.mock import MagicMock, patch
import pytest
from flask import Flask
from docsgpt.api.user.resource_access import set_settings
from docsgpt.security.encryption import decrypt_credentials, encrypt_credentials
from docsgpt.storage.db.repositories.team_members import TeamMembersRepository
from docsgpt.storage.db.repositories.team_resource_grants import (
TeamResourceGrantsRepository,
)
from docsgpt.storage.db.repositories.teams import TeamsRepository
from docsgpt.storage.db.repositories.user_tool_preferences import (
UserToolPreferencesRepository,
)
from docsgpt.storage.db.repositories.user_tools import UserToolsRepository
from docsgpt.storage.db.repositories.users import UsersRepository
OWNER = "alice"
@pytest.fixture
def app():
return Flask(__name__)
@contextmanager
def _patch_db(conn):
@contextmanager
def _yield():
yield conn
with patch("docsgpt.api.user.tools.routes.db_session", _yield), patch(
"docsgpt.api.user.tools.routes.db_readonly", _yield
), patch("docsgpt.api.user.tools.mcp.db_session", _yield), patch(
"docsgpt.api.user.tools.mcp.db_readonly", _yield
), patch("docsgpt.api.user.tools.routes.validate_url"), patch(
"docsgpt.api.user.tools.mcp.validate_url"
):
# URL validation resolves DNS; the SSRF gate has its own tests.
yield
def _call(app, conn, resource_cls, user, *, method="post", json=None, path="/api/x"):
with _patch_db(conn), app.test_request_context(path, method=method.upper(), json=json):
from flask import request
request.decoded_token = {"sub": user}
return getattr(resource_cls(), method)()
def _share(conn, tool_id, member, level, *, team_name="Acme"):
team = TeamsRepository(conn).create(team_name, f"t-{uuid.uuid4().hex[:8]}", OWNER)
TeamMembersRepository(conn).add_member(str(team["id"]), member)
TeamResourceGrantsRepository(conn).grant(
str(team["id"]), "tool", str(tool_id), OWNER, OWNER, access_level=level
)
return team
def _tool(conn, name="read_webpage", config=None, status=True, owner=OWNER):
return UserToolsRepository(conn).create(
owner, name, config=config or {}, display_name=name, description="",
actions=[{"name": "act", "active": True, "require_approval": False}], status=status,
)
def _api_config(url="https://api.example.com/users", header_value="sk-secret", query_value="q-secret"):
return {
"actions": {
"get_users": {
"name": "get_users",
"description": "Get users",
"url": url,
"method": "GET",
"active": True,
"headers": {"type": "object", "properties": {
"X-Key": {"type": "string", "description": "k", "value": header_value,
"filled_by_llm": False},
}},
"query_params": {"type": "object", "properties": {
"token": {"type": "string", "description": "t", "value": query_value,
"filled_by_llm": False},
"limit": {"type": "integer", "description": "l", "value": "", "filled_by_llm": True},
}},
"body": {"type": "object", "properties": {}},
}
}
}
def _row(conn, tool_id, owner=OWNER):
return UserToolsRepository(conn).get(str(tool_id), owner)
def _runtime_action(conn, tool_id, owner=OWNER):
from docsgpt.agents.tool_executor import api_tool_action_with_secrets
row = _row(conn, tool_id, owner)
return api_tool_action_with_secrets(row, "get_users", owner)
# ---------------------------------------------------------------------------
# 1. GET /api/get_tools
# ---------------------------------------------------------------------------
class TestGetToolsAccess:
def _tools(self, app, conn, user):
from docsgpt.api.user.tools.routes import GetTools
resp = _call(app, conn, GetTools, user, method="get", path="/api/get_tools")
assert resp.status_code == 200
return {t["id"]: t for t in resp.json["tools"] if not t.get("default") and not t.get("builtin")}
def test_owner_row_carries_owner_payload_and_in_chat_from_status(self, app, pg_conn):
tool = _tool(pg_conn, status=False)
row = self._tools(app, pg_conn, OWNER)[str(tool["id"])]
assert row["access"] == "owner"
assert {"delete", "share", "manage_settings", "edit"} <= set(row["allowed_actions"])
assert row["allowed_actions"] == sorted(row["allowed_actions"])
assert row["in_chat"] is False
assert row["ownership"] == "user"
def test_viewer_row_payload_shared_via_owner_label_and_preference(self, app, pg_conn):
UsersRepository(pg_conn).upsert(OWNER, email="alice@example.com")
tool = _tool(pg_conn)
_share(pg_conn, tool["id"], "bob", "viewer", team_name="Research")
row = self._tools(app, pg_conn, "bob")[str(tool["id"])]
assert row["access"] == "viewer"
assert row["allowed_actions"] == ["use", "use_in_own"]
assert row["in_chat"] is False # sharing never adds to chats
assert row["shared_via"] == "Research"
assert row["owner_label"] == "alice@example.com"
assert row["ownership"] == "team" and row["team_access"] == "viewer"
UserToolPreferencesRepository(pg_conn).set_in_chat("bob", str(tool["id"]), True)
assert self._tools(app, pg_conn, "bob")[str(tool["id"])]["in_chat"] is True
def test_owner_label_null_without_user_row(self, app, pg_conn):
tool = _tool(pg_conn)
_share(pg_conn, tool["id"], "bob", "editor")
row = self._tools(app, pg_conn, "bob")[str(tool["id"])]
assert row["owner_label"] is None
assert "edit" in row["allowed_actions"] and "delete" not in row["allowed_actions"]
def test_default_rows_carry_in_chat(self, app, pg_conn):
from docsgpt.api.user.tools.routes import GetTools
resp = _call(app, pg_conn, GetTools, OWNER, method="get", path="/api/get_tools")
defaults = [t for t in resp.json["tools"] if t.get("default")]
assert all(t["in_chat"] == t["status"] for t in defaults)
def test_api_tool_secret_values_masked_for_owner_and_grantee(self, app, pg_conn):
from docsgpt.api.user.tools.routes import _seal_api_tool_secrets
sealed = _seal_api_tool_secrets(_api_config(), {}, OWNER)
tool = _tool(pg_conn, name="api_tool", config=sealed)
_share(pg_conn, tool["id"], "bob", "viewer")
for user in (OWNER, "bob"):
row = self._tools(app, pg_conn, user)[str(tool["id"])]
action = row["config"]["actions"]["get_users"]
header = action["headers"]["properties"]["X-Key"]
assert header["value"] == "" and header["has_value"] is True
token = action["query_params"]["properties"]["token"]
assert token["value"] == "" and token["has_value"] is True
limit = action["query_params"]["properties"]["limit"]
assert limit["value"] == "" and limit.get("has_value") is False
assert "encrypted_action_secrets" not in row["config"]
assert "encrypted_credentials" not in row["config"]
def test_legacy_plaintext_api_tool_values_masked(self, app, pg_conn):
tool = _tool(pg_conn, name="api_tool", config=_api_config())
row = self._tools(app, pg_conn, OWNER)[str(tool["id"])]
header = row["config"]["actions"]["get_users"]["headers"]["properties"]["X-Key"]
assert header == {**header, "value": "", "has_value": True}
# ---------------------------------------------------------------------------
# 2. api_tool secret storage
# ---------------------------------------------------------------------------
class TestApiToolSecrets:
def _save(self, app, conn, user, tool_id, config):
from docsgpt.api.user.tools.routes import UpdateTool
return _call(app, conn, UpdateTool, user, json={"id": str(tool_id), "config": config})
def _masked(self, config):
import copy
out = copy.deepcopy(config)
for section in ("headers", "query_params"):
for spec in out["actions"]["get_users"][section]["properties"].values():
spec["has_value"] = bool(spec.get("value"))
spec["value"] = ""
return out
def test_values_stored_encrypted_not_plaintext(self, app, pg_conn):
tool = _tool(pg_conn, name="api_tool", config={})
assert self._save(app, pg_conn, OWNER, tool["id"], _api_config()).status_code == 200
stored = _row(pg_conn, tool["id"])["config"]
header = stored["actions"]["get_users"]["headers"]["properties"]["X-Key"]
assert header["value"] == ""
assert stored["encrypted_action_secrets"]
blob = decrypt_credentials(stored["encrypted_action_secrets"], OWNER)
assert blob["get_users"]["headers"]["X-Key"] == "sk-secret"
action = _runtime_action(pg_conn, tool["id"])
assert action["headers"]["properties"]["X-Key"]["value"] == "sk-secret"
assert action["query_params"]["properties"]["token"]["value"] == "q-secret"
def test_empty_value_with_has_value_keeps_stored_secret(self, app, pg_conn):
tool = _tool(pg_conn, name="api_tool", config={})
self._save(app, pg_conn, OWNER, tool["id"], _api_config())
masked = self._masked(_api_config())
masked["actions"]["get_users"]["description"] = "Renamed"
assert self._save(app, pg_conn, OWNER, tool["id"], masked).status_code == 200
action = _runtime_action(pg_conn, tool["id"])
assert action["description"] == "Renamed"
assert action["headers"]["properties"]["X-Key"]["value"] == "sk-secret"
def test_new_value_replaces_and_cleared_has_value_drops(self, app, pg_conn):
tool = _tool(pg_conn, name="api_tool", config={})
self._save(app, pg_conn, OWNER, tool["id"], _api_config())
cfg = self._masked(_api_config())
cfg["actions"]["get_users"]["headers"]["properties"]["X-Key"]["value"] = "sk-new"
cfg["actions"]["get_users"]["query_params"]["properties"]["token"]["has_value"] = False
self._save(app, pg_conn, OWNER, tool["id"], cfg)
action = _runtime_action(pg_conn, tool["id"])
assert action["headers"]["properties"]["X-Key"]["value"] == "sk-new"
assert action["query_params"]["properties"]["token"]["value"] == ""
def test_legacy_plaintext_reencrypted_on_save(self, app, pg_conn):
tool = _tool(pg_conn, name="api_tool", config=_api_config())
# Runtime reads the legacy plaintext value transparently.
assert _runtime_action(pg_conn, tool["id"])["headers"]["properties"]["X-Key"]["value"] == "sk-secret"
self._save(app, pg_conn, OWNER, tool["id"], self._masked(_api_config()))
stored = _row(pg_conn, tool["id"])["config"]
assert stored["actions"]["get_users"]["headers"]["properties"]["X-Key"]["value"] == ""
assert _runtime_action(pg_conn, tool["id"])["headers"]["properties"]["X-Key"]["value"] == "sk-secret"
def test_editor_save_encrypts_with_owner_key(self, app, pg_conn):
tool = _tool(pg_conn, name="api_tool", config={})
_share(pg_conn, tool["id"], "bob", "editor")
assert self._save(app, pg_conn, "bob", tool["id"], _api_config()).status_code == 200
stored = _row(pg_conn, tool["id"])["config"]
assert decrypt_credentials(stored["encrypted_action_secrets"], OWNER)
assert UserToolsRepository(pg_conn).list_for_user("bob") == []
def test_url_host_change_clears_secrets_and_requires_new_ones(self, app, pg_conn):
tool = _tool(pg_conn, name="api_tool", config={})
self._save(app, pg_conn, OWNER, tool["id"], _api_config())
moved = self._masked(_api_config(url="https://evil.example.org/users"))
resp = self._save(app, pg_conn, OWNER, tool["id"], moved)
assert resp.status_code == 400
assert "credentials" in resp.json["message"].lower()
# Same host, different path: stored values kept.
same_host = self._masked(_api_config(url="https://api.example.com/v2/users"))
assert self._save(app, pg_conn, OWNER, tool["id"], same_host).status_code == 200
assert _runtime_action(pg_conn, tool["id"])["headers"]["properties"]["X-Key"]["value"] == "sk-secret"
@pytest.mark.parametrize("url", [
"http://api.example.com/users", # https -> http: cleartext
"https://api.example.com:8443/users", # another port, maybe another service
])
def test_url_scheme_or_port_change_requires_new_secrets(self, app, pg_conn, url):
tool = _tool(pg_conn, name="api_tool", config={})
self._save(app, pg_conn, OWNER, tool["id"], _api_config())
resp = self._save(app, pg_conn, OWNER, tool["id"], self._masked(_api_config(url=url)))
assert resp.status_code == 400
assert "credentials" in resp.json["message"].lower()
def test_url_with_explicit_default_port_keeps_secrets(self, app, pg_conn):
tool = _tool(pg_conn, name="api_tool", config={})
self._save(app, pg_conn, OWNER, tool["id"], _api_config())
same = self._masked(_api_config(url="https://API.example.com:443/users"))
assert self._save(app, pg_conn, OWNER, tool["id"], same).status_code == 200
assert _runtime_action(pg_conn, tool["id"])["headers"]["properties"]["X-Key"]["value"] == "sk-secret"
def test_url_host_change_with_new_secrets_succeeds(self, app, pg_conn):
tool = _tool(pg_conn, name="api_tool", config={})
self._save(app, pg_conn, OWNER, tool["id"], _api_config())
moved = _api_config(url="https://other.example.org/users", header_value="sk-2", query_value="q-2")
assert self._save(app, pg_conn, OWNER, tool["id"], moved).status_code == 200
action = _runtime_action(pg_conn, tool["id"])
assert action["headers"]["properties"]["X-Key"]["value"] == "sk-2"
# ---------------------------------------------------------------------------
# 3. Write actions
# ---------------------------------------------------------------------------
class TestToolWrites:
def test_update_tool_rename_by_role(self, app, pg_conn):
from docsgpt.api.user.tools.routes import UpdateTool
tool = _tool(pg_conn)
_share(pg_conn, tool["id"], "ed", "editor")
_share(pg_conn, tool["id"], "vi", "viewer")
body = {"id": str(tool["id"]), "customName": "Renamed"}
assert _call(app, pg_conn, UpdateTool, "ed", json=body).status_code == 200
assert _row(pg_conn, tool["id"])["custom_name"] == "Renamed"
resp = _call(app, pg_conn, UpdateTool, "vi", json=body)
assert resp.status_code == 403 and resp.json["success"] is False
assert _call(app, pg_conn, UpdateTool, "stranger", json=body).status_code == 404
def test_update_tool_config_needs_edit_credentials(self, app, pg_conn):
from docsgpt.api.user.tools.routes import UpdateTool, UpdateToolConfig
tool = _tool(pg_conn, name="brave", config={})
_share(pg_conn, tool["id"], "ed", "editor")
set_settings(pg_conn, "tool", str(tool["id"]), {"editors_can_change_credentials": False}, OWNER)
body = {"id": str(tool["id"]), "config": {"token": "x"}}
assert _call(app, pg_conn, UpdateToolConfig, "ed", json=body).status_code == 403
assert _call(app, pg_conn, UpdateTool, "ed", json=body).status_code == 403
set_settings(pg_conn, "tool", str(tool["id"]), {"editors_can_change_credentials": True}, OWNER)
assert _call(app, pg_conn, UpdateToolConfig, "ed", json=body).status_code == 200
def test_update_tool_config_encrypts_with_owner_key(self, app, pg_conn):
from docsgpt.api.user.tools.routes import UpdateToolConfig
tool = _tool(pg_conn, name="mcp_tool", config={"server_url": "https://mcp.example.com/x",
"auth_type": "bearer"})
_share(pg_conn, tool["id"], "ed", "editor")
body = {"id": str(tool["id"]), "config": {"server_url": "https://mcp.example.com/x",
"auth_type": "bearer", "bearer_token": "tok"}}
assert _call(app, pg_conn, UpdateToolConfig, "ed", json=body).status_code == 200
stored = _row(pg_conn, tool["id"])["config"]
assert decrypt_credentials(stored["encrypted_credentials"], OWNER) == {"bearer_token": "tok"}
def test_update_tool_config_host_change_clears_stored_secrets(self, app, pg_conn):
from docsgpt.api.user.tools.routes import UpdateToolConfig
cfg = {"server_url": "https://mcp.example.com/x", "auth_type": "bearer",
"encrypted_credentials": encrypt_credentials({"bearer_token": "tok"}, OWNER)}
tool = _tool(pg_conn, name="mcp_tool", config=cfg)
body = {"id": str(tool["id"]), "config": {"server_url": "https://other.example.org/x",
"auth_type": "bearer"}}
resp = _call(app, pg_conn, UpdateToolConfig, OWNER, json=body)
assert resp.status_code == 400
assert "credentials" in resp.json["message"].lower()
stored = _row(pg_conn, tool["id"])["config"]
assert stored["server_url"] == "https://mcp.example.com/x"
def test_update_tool_config_scheme_change_clears_stored_secrets(self, app, pg_conn):
from docsgpt.api.user.tools.routes import UpdateToolConfig
cfg = {"server_url": "https://mcp.example.com/x", "auth_type": "bearer",
"encrypted_credentials": encrypt_credentials({"bearer_token": "tok"}, OWNER)}
tool = _tool(pg_conn, name="mcp_tool", config=cfg)
body = {"id": str(tool["id"]), "config": {"server_url": "http://mcp.example.com/x",
"auth_type": "bearer"}}
resp = _call(app, pg_conn, UpdateToolConfig, OWNER, json=body)
assert resp.status_code == 400
assert _row(pg_conn, tool["id"])["config"]["server_url"] == "https://mcp.example.com/x"
def test_api_tool_description_edit_needs_only_edit(self, app, pg_conn):
from docsgpt.api.user.tools.routes import UpdateTool, _seal_api_tool_secrets
tool = _tool(pg_conn, name="api_tool", config=_seal_api_tool_secrets(_api_config(), {}, OWNER))
_share(pg_conn, tool["id"], "ed", "editor")
set_settings(pg_conn, "tool", str(tool["id"]), {"editors_can_change_credentials": False}, OWNER)
cfg = TestApiToolSecrets()._masked(_api_config())
cfg["actions"]["get_users"]["description"] = "Edited"
body = {"id": str(tool["id"]), "config": cfg}
assert _call(app, pg_conn, UpdateTool, "ed", json=body).status_code == 200
# Changing the URL (or a secret value) needs edit_credentials.
cfg["actions"]["get_users"]["url"] = "https://api.example.com/v2"
assert _call(app, pg_conn, UpdateTool, "ed", json=body).status_code == 403
def test_update_tool_actions_by_role(self, app, pg_conn):
from docsgpt.api.user.tools.routes import UpdateToolActions
tool = _tool(pg_conn)
_share(pg_conn, tool["id"], "ed", "editor")
_share(pg_conn, tool["id"], "vi", "viewer")
actions = [{"name": "act", "active": True, "require_approval": True}]
body = {"id": str(tool["id"]), "actions": actions}
assert _call(app, pg_conn, UpdateToolActions, "ed", json=body).status_code == 200
assert _row(pg_conn, tool["id"])["actions"][0]["require_approval"] is True
assert _call(app, pg_conn, UpdateToolActions, "vi", json=body).status_code == 403
assert _call(app, pg_conn, UpdateToolActions, "nobody", json=body).status_code == 404
def test_delete_tool_by_role_cleans_grants_and_settings(self, app, pg_conn):
from sqlalchemy import text
from docsgpt.api.user.tools.routes import DeleteTool
tool = _tool(pg_conn)
tid = str(tool["id"])
_share(pg_conn, tid, "ed", "editor")
set_settings(pg_conn, "tool", tid, {"editors_can_share": True}, OWNER)
assert _call(app, pg_conn, DeleteTool, "ed", json={"id": tid}).status_code == 403
assert _call(app, pg_conn, DeleteTool, "nobody", json={"id": tid}).status_code == 404
assert _call(app, pg_conn, DeleteTool, OWNER, json={"id": tid}).status_code == 200
assert _row(pg_conn, tid) is None
grants = pg_conn.execute(
text("SELECT count(*) FROM team_resource_grants WHERE resource_id = CAST(:id AS uuid)"), {"id": tid}
).scalar()
settings_rows = pg_conn.execute(
text("SELECT count(*) FROM resource_share_settings WHERE resource_id = CAST(:id AS uuid)"), {"id": tid}
).scalar()
assert grants == 0 and settings_rows == 0
# ---------------------------------------------------------------------------
# 5. POST /api/update_tool_status
# ---------------------------------------------------------------------------
class TestRemovedConnectionNote:
"""Only removing a connection notes it on a kept tool; config saves can't fake or clear it."""
@pytest.mark.parametrize("route", ["UpdateToolConfig", "UpdateTool"])
def test_a_config_save_cannot_set_the_note(self, app, pg_conn, route):
from docsgpt.api.user.tools import routes
tool = _tool(pg_conn, name="brave", config={})
body = {"id": str(tool["id"]), "config": {"token": "x", "removed_connection": "brave"}}
assert _call(app, pg_conn, getattr(routes, route), OWNER, json=body).status_code == 200
assert "removed_connection" not in _row(pg_conn, tool["id"])["config"]
@pytest.mark.parametrize("route", ["UpdateToolConfig", "UpdateTool"])
def test_a_config_save_keeps_the_stored_note(self, app, pg_conn, route):
from docsgpt.api.user.tools import routes
tool = _tool(pg_conn, name="brave", config={"removed_connection": "brave"})
for config in ({"token": "x"}, {"token": "y", "removed_connection": None}):
body = {"id": str(tool["id"]), "config": config}
assert _call(app, pg_conn, getattr(routes, route), OWNER, json=body).status_code == 200
assert _row(pg_conn, tool["id"])["config"]["removed_connection"] == "brave"
def test_an_mcp_save_drops_the_note(self):
from docsgpt.api.user.tools.mcp import _sanitize_mcp_transport
cfg = {"transport_type": "http", "removed_connection": "github"}
_sanitize_mcp_transport(cfg)
assert "removed_connection" not in cfg
class TestUpdateToolStatusRoles:
def test_owner_writes_status(self, app, pg_conn):
from docsgpt.api.user.tools.routes import UpdateToolStatus
tool = _tool(pg_conn, status=True)
body = {"id": str(tool["id"]), "status": False}
assert _call(app, pg_conn, UpdateToolStatus, OWNER, json=body).status_code == 200
assert _row(pg_conn, tool["id"])["status"] is False
def test_grantee_writes_personal_preference(self, app, pg_conn):
from docsgpt.api.user.tools.routes import UpdateToolStatus
tool = _tool(pg_conn, status=False)
_share(pg_conn, tool["id"], "bob", "viewer")
body = {"id": str(tool["id"]), "status": True}
assert _call(app, pg_conn, UpdateToolStatus, "bob", json=body).status_code == 200
assert _row(pg_conn, tool["id"])["status"] is False # owner's switch untouched
prefs = UserToolPreferencesRepository(pg_conn)
assert prefs.in_chat_many("bob", [str(tool["id"])]) == {str(tool["id"]): True}
def test_grantee_without_use_in_own_is_forbidden(self, app, pg_conn):
from docsgpt.api.user.tools.routes import UpdateToolStatus
tool = _tool(pg_conn)
_share(pg_conn, tool["id"], "bob", "viewer")
set_settings(pg_conn, "tool", str(tool["id"]), {"viewers_can_use_in_agents": False}, OWNER)
body = {"id": str(tool["id"]), "status": True}
assert _call(app, pg_conn, UpdateToolStatus, "bob", json=body).status_code == 403
assert _call(app, pg_conn, UpdateToolStatus, "nobody", json=body).status_code == 404
# ---------------------------------------------------------------------------
# 4. MCP save
# ---------------------------------------------------------------------------
def _mcp_tool(conn, url="https://mcp.example.com/mcp", secrets=None, auth_type="bearer"):
cfg = {"server_url": url, "auth_type": auth_type, "transport_type": "http"}
if secrets:
cfg["encrypted_credentials"] = encrypt_credentials(secrets, OWNER)
return UserToolsRepository(conn).create(
OWNER, "mcp_tool", config=cfg, display_name="M", custom_name="M", description="",
actions=[{"name": "old", "active": True}], status=True,
)
def _stored_mcp_secret(conn, tool_id):
"""The secret a saved MCP tool runs with, and whose key it is under.
A save moves the secret onto a connection of the tool's owner (see
``docsgpt/connectors``); a tool without one keeps it in its config.
"""
from docsgpt.connectors import service
from docsgpt.storage.db.repositories.connector_sessions import ConnectorSessionsRepository
row = UserToolsRepository(conn).get_any(str(tool_id), OWNER)
if not row.get("connection_id"):
return decrypt_credentials(row["config"]["encrypted_credentials"], OWNER), OWNER
assert "encrypted_credentials" not in row["config"]
connection = ConnectorSessionsRepository(conn).get(str(row["connection_id"]))
return service.get_credentials(connection), connection["user_id"]
def _mcp_connection_tool(conn, secrets, url="https://mcp.example.com/mcp"):
"""An MCP tool whose key lives on an owner's connection, as a save leaves it."""
from docsgpt.connectors import catalog, service
connection, _ = service.create_api_key_connection(
conn, OWNER, catalog.get_definition("custom_mcp"), secrets,
server_url=catalog.base_url(url), display_name="M",
)
return UserToolsRepository(conn).create(
OWNER, "mcp_tool", config={"server_url": url, "auth_type": "bearer", "transport_type": "http"},
display_name="M", custom_name="M", description="",
actions=[{"name": "old", "active": True}], status=True, connection_id=str(connection["id"]),
), connection
class TestMCPSaveAccess:
def _save(self, app, conn, user, body, discovered=None):
from docsgpt.api.user.tools.mcp import MCPServerSave
fake = MagicMock()
fake.get_actions_metadata.return_value = discovered or [{"name": "t1"}]
with patch("docsgpt.api.user.tools.mcp.MCPTool", return_value=fake) as cls:
resp = _call(app, conn, MCPServerSave, user, json=body)
return resp, cls
def _count(self, conn):
from sqlalchemy import text
return conn.execute(text("SELECT count(*) FROM user_tools WHERE name = 'mcp_tool'")).scalar()
def test_editor_updates_owner_row_no_duplicate(self, app, pg_conn):
tool = _mcp_tool(pg_conn, secrets={"bearer_token": "tok"})
_share(pg_conn, tool["id"], "ed", "editor")
body = {"id": str(tool["id"]), "displayName": "Renamed",
"config": {"server_url": "https://mcp.example.com/mcp", "auth_type": "bearer",
"transport_type": "http"}}
resp, cls = self._save(app, pg_conn, "ed", body)
assert resp.status_code == 200, resp.json
assert resp.json["id"] == str(tool["id"])
assert self._count(pg_conn) == 1
row = _row(pg_conn, tool["id"])
assert row["display_name"] == "Renamed"
# Stored creds were reused for discovery and kept, encrypted with the owner's key.
assert cls.call_args.kwargs.get("user_id") == OWNER
assert cls.call_args.kwargs["config"]["auth_credentials"] == {"bearer_token": "tok"}
assert _stored_mcp_secret(pg_conn, tool["id"]) == ({"bearer_token": "tok"}, OWNER)
def test_viewer_and_stranger_cannot_save_or_duplicate(self, app, pg_conn):
tool = _mcp_tool(pg_conn, secrets={"bearer_token": "tok"})
_share(pg_conn, tool["id"], "vi", "viewer")
body = {"id": str(tool["id"]), "displayName": "X",
"config": {"server_url": "https://mcp.example.com/mcp", "auth_type": "bearer",
"transport_type": "http", "bearer_token": "new"}}
assert self._save(app, pg_conn, "vi", body)[0].status_code == 403
assert self._save(app, pg_conn, "nobody", body)[0].status_code == 404
assert self._count(pg_conn) == 1
def test_host_change_without_credentials_is_rejected(self, app, pg_conn):
tool = _mcp_tool(pg_conn, secrets={"bearer_token": "tok"})
body = {"id": str(tool["id"]), "displayName": "M",
"config": {"server_url": "https://elsewhere.example.org/mcp", "auth_type": "bearer",
"transport_type": "http"}}
resp, cls = self._save(app, pg_conn, OWNER, body)
assert resp.status_code == 400
assert resp.json["message"] == "Enter credentials for the new server"
assert not cls.called
assert _row(pg_conn, tool["id"])["config"]["server_url"] == "https://mcp.example.com/mcp"
@pytest.mark.parametrize("url", ["http://mcp.example.com/mcp", "https://mcp.example.com:8443/mcp"])
def test_scheme_or_port_change_without_credentials_is_rejected(self, app, pg_conn, url):
tool = _mcp_tool(pg_conn, secrets={"bearer_token": "tok"})
_share(pg_conn, tool["id"], "ed", "editor")
body = {"id": str(tool["id"]), "displayName": "M",
"config": {"server_url": url, "auth_type": "bearer", "transport_type": "http"}}
resp, cls = self._save(app, pg_conn, "ed", body)
assert resp.status_code == 400
assert not cls.called
assert _row(pg_conn, tool["id"])["config"]["server_url"] == "https://mcp.example.com/mcp"
def test_host_change_with_new_credentials_replaces_them(self, app, pg_conn):
tool = _mcp_tool(pg_conn, secrets={"bearer_token": "tok"})
body = {"id": str(tool["id"]), "displayName": "M",
"config": {"server_url": "https://elsewhere.example.org/mcp", "auth_type": "bearer",
"transport_type": "http", "bearer_token": "tok2"}}
resp, _ = self._save(app, pg_conn, OWNER, body)
assert resp.status_code == 200
assert _row(pg_conn, tool["id"])["config"]["server_url"] == "https://elsewhere.example.org/mcp"
assert _stored_mcp_secret(pg_conn, tool["id"]) == ({"bearer_token": "tok2"}, OWNER)
def test_empty_secret_reuses_the_connections_key(self, app, pg_conn):
tool, connection = _mcp_connection_tool(pg_conn, {"bearer_token": "tok"})
_share(pg_conn, tool["id"], "ed", "editor")
body = {"id": str(tool["id"]), "displayName": "Renamed",
"config": {"server_url": "https://mcp.example.com/mcp", "auth_type": "bearer",
"transport_type": "http"}}
resp, cls = self._save(app, pg_conn, "ed", body)
assert resp.status_code == 200, resp.json
assert cls.call_args.kwargs["config"]["auth_credentials"] == {"bearer_token": "tok"}
assert cls.call_args.kwargs["user_id"] == OWNER
assert str(_row(pg_conn, tool["id"])["connection_id"]) == str(connection["id"])
def test_editors_new_key_does_not_rotate_the_owners_connection(self, app, pg_conn):
from docsgpt.connectors import service
from docsgpt.storage.db.repositories.connector_sessions import ConnectorSessionsRepository
tool, connection = _mcp_connection_tool(pg_conn, {"bearer_token": "tok"})
_share(pg_conn, tool["id"], "ed", "editor")
body = {"id": str(tool["id"]), "displayName": "M",
"config": {"server_url": "https://mcp.example.com/mcp", "auth_type": "bearer",
"transport_type": "http", "bearer_token": "tok2"}}
resp, _ = self._save(app, pg_conn, "ed", body)
assert resp.status_code == 200, resp.json
assert _stored_mcp_secret(pg_conn, tool["id"]) == ({"bearer_token": "tok2"}, OWNER)
# The owner's other tools on the old connection keep its key.
old = ConnectorSessionsRepository(pg_conn).get(str(connection["id"]))
assert service.get_credentials(old) == {"bearer_token": "tok"}
def test_moved_server_does_not_inherit_the_connections_key(self, app, pg_conn):
tool, _connection = _mcp_connection_tool(pg_conn, {"bearer_token": "tok"})
body = {"id": str(tool["id"]), "displayName": "M",
"config": {"server_url": "https://elsewhere.example.org/mcp", "auth_type": "bearer",
"transport_type": "http"}}
resp, cls = self._save(app, pg_conn, OWNER, body)
assert resp.status_code == 400
assert not cls.called
def test_editor_blocked_when_credentials_switch_off(self, app, pg_conn):
tool = _mcp_tool(pg_conn, secrets={"bearer_token": "tok"})
_share(pg_conn, tool["id"], "ed", "editor")
set_settings(pg_conn, "tool", str(tool["id"]), {"editors_can_change_credentials": False}, OWNER)
body = {"id": str(tool["id"]), "displayName": "M",
"config": {"server_url": "https://mcp.example.com/mcp", "auth_type": "bearer",
"transport_type": "http"}}
assert self._save(app, pg_conn, "ed", body)[0].status_code == 403
def test_editor_oauth_reconnect_is_owner_only(self, app, pg_conn):
tool = _mcp_tool(pg_conn, auth_type="oauth")
_share(pg_conn, tool["id"], "ed", "editor")
body = {"id": str(tool["id"]), "displayName": "M",
"config": {"server_url": "https://mcp.example.com/mcp", "auth_type": "oauth",
"transport_type": "http", "oauth_task_id": "task-1"}}
resp, _ = self._save(app, pg_conn, "ed", body)
assert resp.status_code == 403
def test_editor_oauth_save_without_reconnect_is_owner_only(self, app, pg_conn):
# A shared OAuth server's connection is the owner's: an editor can't
# save it, even with the same URL (the path, scopes or client could
# still change while reusing the owner's tokens).
tool = _mcp_tool(pg_conn, auth_type="oauth")
_share(pg_conn, tool["id"], "ed", "editor")
body = {"id": str(tool["id"]), "displayName": "Renamed",
"config": {"server_url": "https://mcp.example.com/mcp", "auth_type": "oauth",
"transport_type": "http"}}
resp, _ = self._save(app, pg_conn, "ed", body)
assert resp.status_code == 403
assert _row(pg_conn, tool["id"])["display_name"] == "M"
def test_editor_cannot_switch_oauth_server_to_other_auth(self, app, pg_conn):
tool = _mcp_tool(pg_conn, auth_type="oauth")
_share(pg_conn, tool["id"], "ed", "editor")
body = {"id": str(tool["id"]), "displayName": "M",
"config": {"server_url": "https://mcp.example.com/mcp", "auth_type": "bearer",
"transport_type": "http", "bearer_token": "mine"}}
assert self._save(app, pg_conn, "ed", body)[0].status_code == 403
assert _row(pg_conn, tool["id"])["config"]["auth_type"] == "oauth"
def test_editor_cannot_switch_server_to_oauth(self, app, pg_conn):
tool = _mcp_tool(pg_conn, secrets={"bearer_token": "tok"})
_share(pg_conn, tool["id"], "ed", "editor")
body = {"id": str(tool["id"]), "displayName": "M",
"config": {"server_url": "https://mcp.example.com/mcp", "auth_type": "oauth",
"transport_type": "http"}}
assert self._save(app, pg_conn, "ed", body)[0].status_code == 403
def test_owner_oauth_save_needs_a_completed_sign_in(self, app, pg_conn):
tool = _mcp_tool(pg_conn, auth_type="oauth")
body = {"id": str(tool["id"]), "displayName": "Renamed",
"config": {"server_url": "https://mcp.example.com/mcp", "auth_type": "oauth",
"transport_type": "http"}}
resp, _ = self._save(app, pg_conn, OWNER, body)
assert resp.status_code == 400
assert _row(pg_conn, tool["id"])["display_name"] == "M"
def test_auth_status_includes_team_mcp_tools(self, app, pg_conn):
from docsgpt.api.user.tools.mcp import MCPAuthStatus
tool = _mcp_tool(pg_conn, secrets={"bearer_token": "tok"})
_share(pg_conn, tool["id"], "vi", "viewer")
resp = _call(app, pg_conn, MCPAuthStatus, "vi", method="get", path="/api/mcp_server/auth_status")
assert resp.json["statuses"] == {str(tool["id"]): "configured"}
class TestMCPTestEndpointAccess:
def _test(self, app, conn, user, body):
from docsgpt.api.user.tools.mcp import TestMCPServerConfig
fake = MagicMock()
fake.test_connection.return_value = {"success": True, "tools_count": 1, "tools": []}
with patch("docsgpt.api.user.tools.mcp.MCPTool", return_value=fake) as cls:
resp = _call(app, conn, TestMCPServerConfig, user, json=body)
return resp, cls
def _body(self, tool, url="https://mcp.example.com/mcp", **extra):
return {"id": str(tool["id"]), "config": {"server_url": url, "auth_type": "bearer",
"transport_type": "http", **extra}}
def test_empty_secret_uses_stored_one_as_owner(self, app, pg_conn):
tool = _mcp_tool(pg_conn, secrets={"bearer_token": "tok"})
_share(pg_conn, tool["id"], "ed", "editor")
resp, cls = self._test(app, pg_conn, "ed", self._body(tool))
assert resp.status_code == 200 and resp.json["success"] is True
assert cls.call_args.kwargs["config"]["auth_credentials"] == {"bearer_token": "tok"}
assert cls.call_args.kwargs["user_id"] == OWNER
def test_host_change_without_secret_is_400(self, app, pg_conn):
tool = _mcp_tool(pg_conn, secrets={"bearer_token": "tok"})
resp, cls = self._test(app, pg_conn, OWNER, self._body(tool, url="https://new.example.org/mcp"))
assert resp.status_code == 400
assert resp.json["message"] == "Enter credentials for the new server"
assert not cls.called
resp, cls = self._test(app, pg_conn, OWNER, self._body(tool, url="https://new.example.org/mcp",
bearer_token="t2"))
assert cls.call_args.kwargs["config"]["auth_credentials"] == {"bearer_token": "t2"}
def test_scheme_downgrade_without_secret_is_400(self, app, pg_conn):
tool = _mcp_tool(pg_conn, secrets={"bearer_token": "tok"})
_share(pg_conn, tool["id"], "ed", "editor")
resp, cls = self._test(app, pg_conn, "ed", self._body(tool, url="http://mcp.example.com/mcp"))
assert resp.status_code == 400
assert not cls.called
def test_viewer_and_stranger_denied(self, app, pg_conn):
tool = _mcp_tool(pg_conn, secrets={"bearer_token": "tok"})
_share(pg_conn, tool["id"], "vi", "viewer")
assert self._test(app, pg_conn, "vi", self._body(tool))[0].status_code == 403
assert self._test(app, pg_conn, "eve", self._body(tool))[0].status_code == 404
def test_editor_test_of_stored_oauth_server_is_owner_only(self, app, pg_conn):
tool = _mcp_tool(pg_conn, auth_type="oauth")
_share(pg_conn, tool["id"], "ed", "editor")
resp, cls = self._test(app, pg_conn, "ed", self._body(tool, bearer_token="mine"))
assert resp.status_code == 403 and not cls.called
def test_editor_oauth_test_is_owner_only(self, app, pg_conn):
tool = _mcp_tool(pg_conn, auth_type="oauth")
_share(pg_conn, tool["id"], "ed", "editor")
body = {"id": str(tool["id"]), "config": {"server_url": "https://mcp.example.com/mcp",
"auth_type": "oauth", "transport_type": "http"}}
resp, cls = self._test(app, pg_conn, "ed", body)
assert resp.status_code == 403 and not cls.called
# ---------------------------------------------------------------------------
# 6. Shared OAuth MCP servers: the connection is the owner's
# ---------------------------------------------------------------------------
class TestSharedOAuthMCPConfig:
"""``/api/update_tool`` and ``/api/update_tool_config`` can't move a shared
OAuth server or switch a shared server to OAuth: OAuth tokens are looked up
by owner + server URL, so either would run on the owner's sign-in
elsewhere. Connection changes on OAuth servers are the owner's."""
OTHER = {"server_url": "https://other-mcp.example.org/mcp", "auth_type": "oauth"}
def _oauth_tool(self, conn):
tool = _mcp_tool(conn, auth_type="oauth")
_share(conn, tool["id"], "ed", "editor")
return tool
def test_update_tool_config_cannot_move_oauth_server(self, app, pg_conn):
from docsgpt.api.user.tools.routes import UpdateToolConfig
tool = self._oauth_tool(pg_conn)
body = {"id": str(tool["id"]), "config": dict(self.OTHER)}
assert _call(app, pg_conn, UpdateToolConfig, "ed", json=body).status_code == 403
assert _row(pg_conn, tool["id"])["config"]["server_url"] == "https://mcp.example.com/mcp"
def test_update_tool_cannot_move_oauth_server(self, app, pg_conn):
from docsgpt.api.user.tools.routes import UpdateTool
tool = self._oauth_tool(pg_conn)
body = {"id": str(tool["id"]), "config": dict(self.OTHER)}
assert _call(app, pg_conn, UpdateTool, "ed", json=body).status_code == 403
assert _row(pg_conn, tool["id"])["config"]["server_url"] == "https://mcp.example.com/mcp"
def test_update_tool_config_cannot_switch_server_to_oauth(self, app, pg_conn):
from docsgpt.api.user.tools.routes import UpdateToolConfig
tool = _mcp_tool(pg_conn, secrets={"bearer_token": "tok"})
_share(pg_conn, tool["id"], "ed", "editor")
body = {"id": str(tool["id"]),
"config": {"server_url": "https://mcp.example.com/mcp", "auth_type": "oauth"}}
assert _call(app, pg_conn, UpdateToolConfig, "ed", json=body).status_code == 403
assert _row(pg_conn, tool["id"])["config"]["auth_type"] == "bearer"
def test_editor_still_renames_oauth_server_without_config(self, app, pg_conn):
from docsgpt.api.user.tools.routes import UpdateTool
tool = self._oauth_tool(pg_conn)
body = {"id": str(tool["id"]), "customName": "Renamed"}
assert _call(app, pg_conn, UpdateTool, "ed", json=body).status_code == 200
assert _row(pg_conn, tool["id"])["custom_name"] == "Renamed"
def test_owner_may_change_oauth_server_config(self, app, pg_conn):
from docsgpt.api.user.tools.routes import UpdateToolConfig
tool = _mcp_tool(pg_conn, auth_type="oauth")
body = {"id": str(tool["id"]), "config": dict(self.OTHER)}
assert _call(app, pg_conn, UpdateToolConfig, OWNER, json=body).status_code == 200
assert _row(pg_conn, tool["id"])["config"]["server_url"] == self.OTHER["server_url"]
# ---------------------------------------------------------------------------
# 7. /api/update_tool keeps fixed values and the tool type
# ---------------------------------------------------------------------------
def _pinned_tool(conn, name="ntfy"):
"""A tool whose ``server_url`` the owner fixed; ``message`` is the model's."""
return UserToolsRepository(conn).create(
OWNER, name, config={}, display_name=name, description="",
actions=[{
"name": "send", "active": True, "require_approval": False,
"parameters": {"type": "object", "properties": {
"server_url": {"type": "string", "filled_by_llm": False, "value": "https://ntfy.example.com"},
"message": {"type": "string", "filled_by_llm": True, "value": ""},
}},
}],
status=True,
)
def _moved_pin(actions):
import copy
out = copy.deepcopy(actions)
out[0]["parameters"]["properties"]["server_url"]["value"] = "https://evil.example"
return out
class TestUpdateToolFixedValues:
def test_editor_cannot_change_a_fixed_value_through_update_tool(self, app, pg_conn):
from docsgpt.api.user.tools.routes import UpdateTool
tool = _pinned_tool(pg_conn)
_share(pg_conn, tool["id"], "ed", "editor")
body = {"id": str(tool["id"]), "actions": _moved_pin(tool["actions"])}
resp = _call(app, pg_conn, UpdateTool, "ed", json=body)
assert resp.status_code == 403
pinned = _row(pg_conn, tool["id"])["actions"][0]["parameters"]["properties"]["server_url"]
assert pinned["value"] == "https://ntfy.example.com"
def test_editor_cannot_release_a_fixed_value_through_update_tool(self, app, pg_conn):
from docsgpt.api.user.tools.routes import UpdateTool
tool = _pinned_tool(pg_conn)
_share(pg_conn, tool["id"], "ed", "editor")
actions = tool["actions"]
actions[0]["parameters"]["properties"]["server_url"]["filled_by_llm"] = True
resp = _call(app, pg_conn, UpdateTool, "ed", json={"id": str(tool["id"]), "actions": actions})
assert resp.status_code == 403
assert _row(pg_conn, tool["id"])["actions"][0]["parameters"]["properties"]["server_url"][
"filled_by_llm"] is False
def test_editor_round_trip_with_other_edits_still_saves(self, app, pg_conn):
from docsgpt.api.user.tools.routes import UpdateTool
tool = _pinned_tool(pg_conn)
_share(pg_conn, tool["id"], "ed", "editor")
actions = tool["actions"]
actions[0]["require_approval"] = True
body = {"id": str(tool["id"]), "name": "ntfy", "customName": "Alerts", "actions": actions}
assert _call(app, pg_conn, UpdateTool, "ed", json=body).status_code == 200
row = _row(pg_conn, tool["id"])
assert row["custom_name"] == "Alerts" and row["actions"][0]["require_approval"] is True
def test_actions_cannot_be_added_through_update_tool(self, app, pg_conn):
from docsgpt.api.user.tools.routes import UpdateTool
tool = _pinned_tool(pg_conn)
actions = [*tool["actions"], {"name": "exfiltrate", "active": True}]
resp = _call(app, pg_conn, UpdateTool, OWNER, json={"id": str(tool["id"]), "actions": actions})
assert resp.status_code == 400
assert [a["name"] for a in _row(pg_conn, tool["id"])["actions"]] == ["send"]
def test_owner_changes_a_fixed_value_through_update_tool(self, app, pg_conn):
from docsgpt.api.user.tools.routes import UpdateTool
tool = _pinned_tool(pg_conn)
body = {"id": str(tool["id"]), "actions": _moved_pin(tool["actions"])}
assert _call(app, pg_conn, UpdateTool, OWNER, json=body).status_code == 200
pinned = _row(pg_conn, tool["id"])["actions"][0]["parameters"]["properties"]["server_url"]
assert pinned["value"] == "https://evil.example"
@pytest.mark.parametrize("user", [OWNER, "ed"])
def test_tool_type_cannot_change(self, app, pg_conn, user):
from docsgpt.api.user.tools.routes import UpdateTool
tool = _pinned_tool(pg_conn, name="telegram")
_share(pg_conn, tool["id"], "ed", "editor")
resp = _call(app, pg_conn, UpdateTool, user, json={"id": str(tool["id"]), "name": "ntfy"})
assert resp.status_code == 400
assert _row(pg_conn, tool["id"])["name"] == "telegram"
# ---------------------------------------------------------------------------
# 8. api_tool: a stored header / query value stays fixed for non-owners
# ---------------------------------------------------------------------------
class TestApiToolFixedValues:
def _tool(self, conn):
from docsgpt.api.user.tools.routes import _seal_api_tool_secrets
tool = _tool(conn, name="api_tool", config=_seal_api_tool_secrets(_api_config(), {}, OWNER))
_share(conn, tool["id"], "ed", "editor")
return tool
def _masked(self):
return TestApiToolSecrets()._masked(_api_config())
def _token(self, cfg):
return cfg["actions"]["get_users"]["query_params"]["properties"]["token"]
@pytest.mark.parametrize("route", ["UpdateTool", "UpdateToolConfig"])
def test_editor_cannot_hand_a_stored_secret_to_the_model(self, app, pg_conn, route):
from docsgpt.api.user.tools import routes
tool = self._tool(pg_conn)
cfg = self._masked()
self._token(cfg)["filled_by_llm"] = True
resp = _call(app, pg_conn, getattr(routes, route), "ed", json={"id": str(tool["id"]), "config": cfg})
assert resp.status_code == 403
token = _runtime_action(pg_conn, tool["id"])["query_params"]["properties"]["token"]
assert token["filled_by_llm"] is False and token["value"] == "q-secret"
@pytest.mark.parametrize("change", [
{"has_value": False},
{"value": "attacker-token"},
])
def test_editor_cannot_clear_or_replace_a_stored_value(self, app, pg_conn, change):
from docsgpt.api.user.tools.routes import UpdateTool
tool = self._tool(pg_conn)
cfg = self._masked()
self._token(cfg).update(change)
resp = _call(app, pg_conn, UpdateTool, "ed", json={"id": str(tool["id"]), "config": cfg})
assert resp.status_code == 403
assert _runtime_action(pg_conn, tool["id"])["query_params"]["properties"]["token"]["value"] == "q-secret"
def test_editor_cannot_drop_a_fixed_parameter_then_re_add_it(self, app, pg_conn):
from docsgpt.api.user.tools.routes import UpdateTool
tool = self._tool(pg_conn)
cfg = self._masked()
del cfg["actions"]["get_users"]["query_params"]["properties"]["token"]
resp = _call(app, pg_conn, UpdateTool, "ed", json={"id": str(tool["id"]), "config": cfg})
assert resp.status_code == 403
def test_editor_still_adds_an_empty_parameter_and_edits_descriptions(self, app, pg_conn):
from docsgpt.api.user.tools.routes import UpdateTool
tool = self._tool(pg_conn)
cfg = self._masked()
cfg["actions"]["get_users"]["description"] = "Edited"
cfg["actions"]["get_users"]["query_params"]["properties"]["page"] = {
"type": "string", "description": "", "value": "", "filled_by_llm": False, "required": False,
}
resp = _call(app, pg_conn, UpdateTool, "ed", json={"id": str(tool["id"]), "config": cfg})
assert resp.status_code == 200, resp.json
action = _runtime_action(pg_conn, tool["id"])
assert action["description"] == "Edited"
assert action["query_params"]["properties"]["token"]["value"] == "q-secret"
def test_owner_may_hand_a_value_to_the_model(self, app, pg_conn):
from docsgpt.api.user.tools.routes import UpdateTool
tool = self._tool(pg_conn)
cfg = self._masked()
self._token(cfg)["filled_by_llm"] = True
assert _call(app, pg_conn, UpdateTool, OWNER, json={"id": str(tool["id"]), "config": cfg}).status_code == 200
assert _runtime_action(pg_conn, tool["id"])["query_params"]["properties"]["token"]["filled_by_llm"] is True
# ---------------------------------------------------------------------------
# 9. A connection-backed MCP tool moves only through /api/mcp_server/save
# ---------------------------------------------------------------------------
class TestConnectionBackedMCPConfig:
SAME = {"server_url": "https://mcp.example.com/mcp", "auth_type": "bearer", "transport_type": "http"}
@pytest.mark.parametrize("route", ["UpdateTool", "UpdateToolConfig"])
@pytest.mark.parametrize("change", [
{"server_url": "https://other.example.org/mcp", "bearer_token": "new"},
{"server_url": "http://mcp.example.com/mcp", "bearer_token": "new"},
{"auth_type": "api_key", "api_key": "new"},
])
def test_moving_the_server_or_auth_is_refused(self, app, pg_conn, route, change):
from docsgpt.api.user.tools import routes
tool, connection = _mcp_connection_tool(pg_conn, {"bearer_token": "tok"})
body = {"id": str(tool["id"]), "config": {**self.SAME, **change}}
resp = _call(app, pg_conn, getattr(routes, route), OWNER, json=body)
assert resp.status_code == 400
assert "mcp_server/save" in resp.json["message"]
row = _row(pg_conn, tool["id"])
assert row["config"]["server_url"] == self.SAME["server_url"]
assert row["config"]["auth_type"] == "bearer"
assert str(row["connection_id"]) == str(connection["id"])
def test_update_tool_config_writes_a_new_key_to_the_connection(self, app, pg_conn):
from docsgpt.api.user.tools.routes import UpdateToolConfig
tool, _connection = _mcp_connection_tool(pg_conn, {"bearer_token": "tok"})
body = {"id": str(tool["id"]), "config": {**self.SAME, "bearer_token": "tok2"}}
assert _call(app, pg_conn, UpdateToolConfig, OWNER, json=body).status_code == 200
assert _stored_mcp_secret(pg_conn, tool["id"]) == ({"bearer_token": "tok2"}, OWNER)
def test_update_tool_config_keeps_the_owners_connection_from_editors(self, app, pg_conn):
from docsgpt.api.user.tools.routes import UpdateToolConfig
tool, _connection = _mcp_connection_tool(pg_conn, {"bearer_token": "tok"})
_share(pg_conn, tool["id"], "ed", "editor")
body = {"id": str(tool["id"]), "config": {**self.SAME, "bearer_token": "tok2"}}
assert _call(app, pg_conn, UpdateToolConfig, "ed", json=body).status_code == 403
assert _stored_mcp_secret(pg_conn, tool["id"]) == ({"bearer_token": "tok"}, OWNER)
def test_same_server_other_path_still_saves(self, app, pg_conn):
from docsgpt.api.user.tools.routes import UpdateTool
tool, connection = _mcp_connection_tool(pg_conn, {"bearer_token": "tok"})
body = {"id": str(tool["id"]), "config": {**self.SAME, "server_url": "https://mcp.example.com/v2/mcp"}}
assert _call(app, pg_conn, UpdateTool, OWNER, json=body).status_code == 200
row = _row(pg_conn, tool["id"])
assert row["config"]["server_url"] == "https://mcp.example.com/v2/mcp"
assert str(row["connection_id"]) == str(connection["id"])