MCP rollback for connectors

This commit is contained in:
Pavel committed 2026-09-29 14:48:18 +04:00
1 parent 0622883ce4
commit 68c1e12b6f
29 files changed
+650 -160

No files matched your search

+42 -28
View File
@@ -25,7 +25,7 @@ from docsgpt.core.json_schema_utils import (
normalize_json_schema_payload,
)
from docsgpt.core.settings import settings
from docsgpt.storage.db.base_repository import looks_like_uuid
from docsgpt.storage.db.base_repository import canonical_uuid, looks_like_uuid
from docsgpt.api.user.resource_access import (
AccessDenied,
agent_refs,
@@ -221,17 +221,17 @@ def _denied(err: AccessDenied):
return make_response(jsonify({"success": False, "message": err.message}), err.status)
def _tool_attachable(conn, tool_id: str, owner_id: str, caller: str) -> bool:
"""Whether ``caller`` may attach ``tool_id`` to an agent owned by ``owner_id``.
def _tool_attachable(conn, tool_id: str, caller: str) -> bool:
"""Whether ``caller`` may newly attach ``tool_id`` to an agent.
Builtin synthetic ids belong to no one. Otherwise the tool must be the
agent owner's (it runs with the owner's credentials) or reach the caller
with ``use_in_own``.
Builtin synthetic ids belong to no one. Otherwise the caller must own the
tool or reach it with ``use_in_own``. The agent owner owning it is not
enough: an editor could otherwise wire the owner's private tool (run with
the owner's credentials) into an agent the editor controls.
Args:
conn: Open database connection.
tool_id: The tool id being attached.
owner_id: The agent's owner.
caller: The user making the change.
Returns:
@@ -240,29 +240,26 @@ def _tool_attachable(conn, tool_id: str, owner_id: str, caller: str) -> bool:
tid = str(tool_id)
if is_synthesized_tool_id(tid):
return True
if UserToolsRepository(conn).get_any(tid, owner_id) is not None:
return True
ra = resolve(conn, "tool", tid, caller)
return ra is not None and ra.can("use_in_own")
def _ref_attachable(conn, resource_type: str, resource_id: str, owner_id: str, caller: str) -> bool:
"""Whether a source/prompt may be referenced by an agent owned by ``owner_id``.
def _ref_attachable(conn, resource_type: str, resource_id: str, caller: str) -> bool:
"""Whether ``caller`` may newly reference a source/prompt from an agent.
Like tools, the caller's own access counts, not the agent owner's.
Args:
conn: Open database connection.
resource_type: ``source`` or ``prompt``.
resource_id: The referenced id.
owner_id: The agent's owner.
caller: The user making the change.
Returns:
True when the agent owner owns it or the caller can ``use`` it.
True when the caller owns it or a team grant reaches them.
"""
if not resource_id:
return True
if owner_id != caller and can_access(conn, resource_type, str(resource_id), owner_id):
return True
return can_access(conn, resource_type, str(resource_id), caller)
@@ -825,11 +822,11 @@ class CreateAgent(Resource):
if src == "default":
continue
if looks_like_uuid(src):
extra_source_ids.append(src)
extra_source_ids.append(canonical_uuid(src))
else:
source_value = data.get("source", "")
if source_value and source_value != "default" and looks_like_uuid(source_value):
source_id_resolved = source_value
source_id_resolved = canonical_uuid(source_value)
# Team-sharing write gate: you may reference sources/prompts you
# own or that a team has shared with you directly. (Transitive
@@ -854,13 +851,17 @@ class CreateAgent(Resource):
# Tools run with the agent owner's credentials: attach only your
# own, or ones a team lets you use in your agents.
for tid in data.get("tools") or []:
if not _tool_attachable(conn, tid, user, user):
if not _tool_attachable(conn, tid, user):
return make_response(
jsonify({"success": False, "message": "Tool not accessible"}),
403,
)
build_data = dict(data)
if isinstance(data.get("tools"), list):
build_data["tools"] = [canonical_uuid(t) for t in data["tools"]]
if looks_like_uuid(data.get("prompt_id")):
build_data["prompt_id"] = canonical_uuid(data["prompt_id"])
build_data["folder_id"] = pg_folder_id
build_data["workflow_id"] = pg_workflow_id
build_data["source_id"] = source_id_resolved
@@ -1087,7 +1088,7 @@ class UpdateAgent(Resource):
if not source_id or source_id == "default":
update_fields["source_id"] = None
elif looks_like_uuid(source_id):
update_fields["source_id"] = source_id
update_fields["source_id"] = canonical_uuid(source_id)
else:
return _reject(
f"Invalid source ID format: {source_id}", user, field
@@ -1102,7 +1103,7 @@ class UpdateAgent(Resource):
if src == "default":
continue
if looks_like_uuid(src):
valid.append(src)
valid.append(canonical_uuid(src))
else:
return _reject(
f"Invalid source ID in list: {src}", user, field
@@ -1130,7 +1131,7 @@ class UpdateAgent(Resource):
tools_list = data.get("tools", [])
if not isinstance(tools_list, list):
return _reject("Tools must be a list", user, field)
update_fields["tools"] = tools_list
update_fields["tools"] = [canonical_uuid(t) for t in tools_list]
elif field == "json_schema":
json_schema = data.get("json_schema")
if json_schema is not None:
@@ -1250,13 +1251,25 @@ class UpdateAgent(Resource):
)
if wf_err:
return wf_err
# Only the owner may point the agent at a different
# workflow: editing rights on this agent extend to
# the graph it uses, so swapping in the workflow of
# another of the owner's agents would hand that
# graph to the editor.
current_workflow = existing_agent.get("workflow_id")
if is_team_editor and pg_workflow_id != (
str(current_workflow) if current_workflow else None
):
return _denied(
AccessDenied(403, "Only the owner can change this agent's workflow")
)
update_fields["workflow_id"] = pg_workflow_id
elif field == "prompt_id":
value = data["prompt_id"]
if not value or value == "default":
update_fields["prompt_id"] = None
elif looks_like_uuid(value):
update_fields["prompt_id"] = value
update_fields["prompt_id"] = canonical_uuid(value)
else:
return _reject(f"Invalid prompt_id: {value}", user, field)
elif field == "allow_system_prompt_override":
@@ -1358,7 +1371,7 @@ class UpdateAgent(Resource):
for sid in referenced_sources:
if str(sid) in existing_source_refs:
continue
if not _ref_attachable(conn, "source", sid, owner_id, user):
if not _ref_attachable(conn, "source", sid, user):
return make_response(
jsonify({"success": False, "message": "Source not accessible"}), 403
)
@@ -1366,16 +1379,17 @@ class UpdateAgent(Resource):
if (
new_prompt_id
and str(new_prompt_id) != str(existing_agent.get("prompt_id") or "")
and not _ref_attachable(conn, "prompt", new_prompt_id, owner_id, user)
and not _ref_attachable(conn, "prompt", new_prompt_id, user)
):
return make_response(
jsonify({"success": False, "message": "Prompt not accessible"}), 403
)
# Tools run with the OWNER's credentials (the agent-key path
# resolves and decrypts them as the owner), so a newly attached
# tool must be the owner's or reach the caller with
# ``use_in_own``. Tools already on the agent stay. Builtin
# synthetic ids belong to no one and are always allowed.
# tool must be the caller's own or reach them with
# ``use_in_own`` -- the owner owning it is not enough. Tools
# already on the agent stay. Builtin synthetic ids belong to no
# one and are always allowed.
if "tools" in update_fields:
existing_tools = {
str(t) for t in (existing_agent.get("tools") or [])
@@ -1383,7 +1397,7 @@ class UpdateAgent(Resource):
for tid in update_fields["tools"] or []:
if str(tid) in existing_tools:
continue
if not _tool_attachable(conn, tid, owner_id, user):
if not _tool_attachable(conn, tid, user):
return make_response(
jsonify(
{"success": False, "message": "Tool not accessible"}
+13 -6
View File
@@ -27,7 +27,7 @@ from typing import Iterable, Optional
from sqlalchemy import Connection, text
from docsgpt.storage.db.base_repository import looks_like_uuid
from docsgpt.storage.db.base_repository import canonical_uuid, looks_like_uuid
from docsgpt.storage.db.repositories.agents import AgentsRepository
from docsgpt.storage.db.repositories.prompts import PromptsRepository
from docsgpt.storage.db.repositories.sources import SourcesRepository
@@ -73,7 +73,7 @@ ACTIONS: dict[str, dict[str, str]] = {
"use": "viewer", # see it and run it inside the owner's shared agents
"use_in_own": "viewer", # add it to my own agents and chats
"edit": "editor", # name, action descriptions, parameters, approval
"edit_credentials": "editor", # secrets, URL, auth, reconnect OAuth (write-only)
"edit_credentials": "editor", # secrets, URL, auth (write-only); OAuth servers stay owner-only
"share": "owner",
"delete": "owner",
"manage_settings": "owner",
@@ -186,14 +186,18 @@ def public_settings(resource_type: str, settings: Optional[dict]) -> list[dict]:
def settings_for(conn: Connection, resource_type: str, resource_id: str) -> dict[str, bool]:
"""The resource's switches, defaults filled in."""
return settings_many(conn, resource_type, [resource_id])[resource_id]
rid = canonical_uuid(str(resource_id))
return settings_many(conn, resource_type, [rid])[rid]
def settings_many(
conn: Connection, resource_type: str, resource_ids: Iterable[str]
) -> dict[str, dict[str, bool]]:
"""``resource_id -> switches`` for many resources in one query."""
ids = [str(r) for r in resource_ids]
"""``resource_id -> switches`` for many resources in one query.
Keys are canonical (lowercase) UUIDs, the form Postgres returns.
"""
ids = [canonical_uuid(str(r)) for r in resource_ids]
out = {rid: default_settings(resource_type) for rid in ids}
uuids = [rid for rid in ids if looks_like_uuid(rid)]
if not uuids:
@@ -298,7 +302,10 @@ def resolve(
repo_cls = _REPO_FOR_TYPE.get(resource_type)
if repo_cls is None or not resource_id or not user_id:
return None
owned = repo_cls(conn).get_any(str(resource_id), user_id)
# Postgres matches any casing but returns lowercase; canonicalise so the
# switch lookup (keyed by the returned id) can't miss.
resource_id = canonical_uuid(str(resource_id))
owned = repo_cls(conn).get_any(resource_id, user_id)
if owned is not None:
rid = str(owned.get("id") or resource_id)
return build(resource_type, rid, "owner", user_id, settings_for(conn, resource_type, rid))
+29 -53
View File
@@ -13,6 +13,7 @@ from docsgpt.api.user.tools.routes import (
_CREDENTIALS_FOR_NEW_SERVER,
_MCP_CREDENTIAL_AUTH_TYPES,
_mcp_host_changed,
check_oauth_mcp_owner_only,
denied_response,
transform_actions,
)
@@ -83,9 +84,6 @@ def _validate_mcp_server_url(config: dict) -> None:
raise ValueError(f"Invalid server URL: {exc}") from exc
_ONLY_OWNER_RECONNECTS = "Only the owner can reconnect this account"
def _existing_mcp_context(tool_id, user, config):
"""Resolve the stored MCP tool a test/save refers to, and its credentials.
@@ -93,14 +91,16 @@ def _existing_mcp_context(tool_id, user, config):
the caller needs ``edit_credentials`` on that tool and everything runs as
its owner. Stored secrets are write-only, so an empty secret field reuses
the stored one while the host is unchanged; a new host never inherits them.
A server that is or would become OAuth is the owner's alone (its tokens
are the owner's sign-in).
Returns:
``(existing_doc, owner_id, is_owner, moved, credentials)``, or a Flask
response (404 / 400) to return as is.
Raises:
AccessDenied: the caller can't see the tool (404) or can't change
its credentials (403).
AccessDenied: the caller can't see the tool (404), can't change its
credentials (403), or isn't the owner of an OAuth server (403).
"""
auth_credentials = _extract_auth_credentials(config)
if not tool_id:
@@ -113,6 +113,7 @@ def _existing_mcp_context(tool_id, user, config):
jsonify({"success": False, "message": "Tool not found or access denied"}), 404,
)
existing_config = existing_doc.get("config") or {}
check_oauth_mcp_owner_only(ra, existing_config, config)
moved = _mcp_host_changed(config, existing_config)
auth_type = config.get("auth_type", "none")
new_secret_keys = set(auth_credentials) - {"api_key_header"}
@@ -170,13 +171,7 @@ class TestMCPServerConfig(Resource):
ctx = _existing_mcp_context(data.get("id"), user, config)
if not isinstance(ctx, tuple):
return ctx
_existing_doc, owner_id, is_owner, _moved, auth_credentials = ctx
if not is_owner and config.get("auth_type") == "oauth":
# An OAuth flow would store tokens under the editor's account
# (and its popup event goes to that account), not the owner's.
return make_response(
jsonify({"success": False, "message": _ONLY_OWNER_RECONNECTS}), 403
)
_existing_doc, owner_id, _is_owner, _moved, auth_credentials = ctx
test_config = config.copy()
test_config["auth_credentials"] = auth_credentials
@@ -279,50 +274,16 @@ class MCPServerSave(Resource):
ctx = _existing_mcp_context(data.get("id"), user, config)
if not isinstance(ctx, tuple):
return ctx
existing_doc, owner_id, is_owner, moved, merged_credentials = ctx
existing_config = (existing_doc or {}).get("config") or {}
existing_doc, owner_id, is_owner, _moved, merged_credentials = ctx
auth_type = config.get("auth_type", "none")
mcp_config = config.copy()
mcp_config["auth_credentials"] = merged_credentials
keep_actions = False
if auth_type == "oauth":
if config.get("oauth_task_id"):
if not is_owner:
# The OAuth flow stores tokens under the account that
# ran it; reconnecting as the owner is owner-only.
return make_response(
jsonify({
"success": False,
"message": _ONLY_OWNER_RECONNECTS,
}),
403,
)
redis_client = get_redis_instance()
manager = MCPOAuthManager(redis_client)
result = manager.get_oauth_status(
config["oauth_task_id"], user
)
if not result.get("status") == "completed":
return make_response(
jsonify(
{
"success": False,
"error": "OAuth failed or not completed. Please try authorizing again.",
}
),
400,
)
actions_metadata = result.get("tools", [])
elif (
existing_doc is not None
and not moved
and existing_config.get("auth_type") == "oauth"
):
# Editing an already-connected server: keep its tools.
actions_metadata = existing_doc.get("actions") or []
keep_actions = True
else:
# Only the owner reaches here for an existing server (see
# ``_existing_mcp_context``), and every OAuth save needs the
# sign-in they just completed.
if not config.get("oauth_task_id"):
return make_response(
jsonify(
{
@@ -332,6 +293,22 @@ class MCPServerSave(Resource):
),
400,
)
redis_client = get_redis_instance()
manager = MCPOAuthManager(redis_client)
result = manager.get_oauth_status(
config["oauth_task_id"], user
)
if not result.get("status") == "completed":
return make_response(
jsonify(
{
"success": False,
"error": "OAuth failed or not completed. Please try authorizing again.",
}
),
400,
)
actions_metadata = result.get("tools", [])
elif auth_type == "none" or merged_credentials:
mcp_tool = MCPTool(config=mcp_config, user_id=owner_id)
mcp_tool.discover_tools()
@@ -356,8 +333,7 @@ class MCPServerSave(Resource):
"redirect_uri",
]:
storage_config.pop(field, None)
# Kept actions already carry the owner's on/off and approval flags.
transformed_actions = actions_metadata if keep_actions else transform_actions(actions_metadata)
transformed_actions = transform_actions(actions_metadata)
display_name = data["displayName"]
description = f"MCP Server: {storage_config.get('server_url', 'Unknown')}"
+34
View File
@@ -350,6 +350,37 @@ def _mcp_host_changed(new_config: dict, existing_config: dict) -> bool:
return bool(old_url) and url_host(old_url) != url_host((new_config or {}).get("server_url"))
SHARED_OAUTH_OWNER_ONLY = "Only the owner can change or reconnect this server"
def check_oauth_mcp_owner_only(
ra: ResourceAccess, existing_config: Optional[dict], new_config: Optional[dict]
) -> None:
"""Keep a shared OAuth MCP server's connection with its owner.
MCP OAuth tokens are looked up by owner + server URL and a shared server
runs as its owner, so a grantee who moved an OAuth server, switched a
server to OAuth, or re-ran its sign-in would be using the owner's account
somewhere the owner never chose. Until connectors own OAuth accounts, any
connection change on a server that is (or would become) OAuth is
owner-only.
Args:
ra: The caller's access to the tool.
existing_config: The stored ``config``.
new_config: The incoming ``config``.
Raises:
AccessDenied: 403 when a non-owner touches an OAuth server's config.
"""
if ra.access == "owner":
return
configs = [c if isinstance(c, dict) else {} for c in (existing_config, new_config)]
auth_types = {c.get("auth_type") for c in configs}
if "oauth" in auth_types:
raise AccessDenied(403, SHARED_OAUTH_OWNER_ONLY)
def _prepare_tool_config(tool_doc: dict, new_config: dict, config_requirements: dict) -> dict:
"""Validate-free merge of an incoming config with the stored one, as the owner.
@@ -776,6 +807,8 @@ class UpdateTool(Resource):
if "config" in data:
tool_name = tool_doc.get("name", data.get("name"))
existing_config = tool_doc.get("config", {}) or {}
if tool_name == "mcp_tool":
check_oauth_mcp_owner_only(ra, existing_config, data["config"])
if tool_name == "api_tool" and not _api_tool_config_needs_credentials(
data["config"], existing_config
):
@@ -873,6 +906,7 @@ class UpdateToolConfig(Resource):
tool_name = tool_doc.get("name")
if tool_name == "mcp_tool":
check_oauth_mcp_owner_only(ra, tool_doc.get("config"), data["config"])
server_url = (data["config"].get("server_url") or "").strip()
if server_url:
try:
+45
View File
@@ -12,6 +12,7 @@ from docsgpt.agents.workflows.cel_evaluator import (
)
from docsgpt.api.user.resource_access import (
AccessDenied,
can_use_ref,
resolve,
sponsor_details,
sponsors_after_save,
@@ -124,6 +125,40 @@ def _node_refs(nodes: List[Dict]) -> List[Tuple[str, str]]:
return refs
def _new_node_ref_denied(
conn, previous_nodes: List[Dict], new_nodes: List[Dict], caller: str
) -> Optional[AccessDenied]:
"""403 for the first node tool/source ``caller`` newly adds but can't use.
A workflow runs as its owner, so an editor saving the owner's graph must
not reference the owner's private tools or sources: the caller's own
access counts (``use_in_own`` for a tool, ``use`` for a source), not the
owner's. Refs already in the stored graph stay, like an agent's.
Args:
conn: Open database connection.
previous_nodes: The stored graph's nodes, in builder shape.
new_nodes: The nodes being saved.
caller: The editor saving.
Returns:
An :class:`AccessDenied` to return, or None when every new ref is fine.
"""
from docsgpt.agents.default_tools import is_synthesized_tool_id
existing = set(_node_refs(previous_nodes))
for resource_type, resource_id in _node_refs(new_nodes):
if (resource_type, resource_id) in existing:
continue
if resource_type == "tool" and is_synthesized_tool_id(resource_id):
continue
if resource_type == "source" and resource_id == "default":
continue
if not can_use_ref(conn, resource_type, resource_id, caller):
return AccessDenied(403, f"{resource_type.capitalize()} not accessible")
return None
def _denied(err: AccessDenied):
"""403/404 in this module's ``error`` shape, plus the shared ``message`` key."""
return make_response(
@@ -639,6 +674,16 @@ class WorkflowDetail(Resource):
nodes_data = normalize_agent_node_json_schemas(nodes_data)
pg_workflow_id = str(workflow["id"])
current_graph_version = get_workflow_graph_version(workflow)
if acting != user_id:
previous_nodes = [
serialize_node(n)
for n in WorkflowNodesRepository(conn).find_by_version(
pg_workflow_id, current_graph_version,
)
]
denied = _new_node_ref_denied(conn, previous_nodes, nodes_data, user_id)
if denied is not None:
return _denied(denied)
next_graph_version = current_graph_version + 1
_write_graph(