This commit is contained in:
Pavel committed 2026-09-29 16:06:05 +04:00
1 parent 39f636a137
commit 258c3fb352
5 files changed
+104 -21

No files matched your search

+4 -3
View File
@@ -12,7 +12,7 @@ from docsgpt.api.user.team_sharing import visible_with_access
from docsgpt.api.user.tools.routes import (
_CREDENTIALS_FOR_NEW_SERVER,
_MCP_CREDENTIAL_AUTH_TYPES,
_mcp_host_changed,
_mcp_origin_changed,
check_oauth_mcp_owner_only,
denied_response,
transform_actions,
@@ -90,7 +90,8 @@ def _existing_mcp_context(tool_id, user, config):
With no ``tool_id`` the caller acts on their own new server. With one,
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.
the stored one while the origin (scheme, host, port) is unchanged; a new
origin never inherits them.
A server that is or would become OAuth is the owner's alone (its tokens
are the owner's sign-in).
@@ -114,7 +115,7 @@ def _existing_mcp_context(tool_id, user, config):
)
existing_config = existing_doc.get("config") or {}
check_oauth_mcp_owner_only(ra, existing_config, config)
moved = _mcp_host_changed(config, existing_config)
moved = _mcp_origin_changed(config, existing_config)
auth_type = config.get("auth_type", "none")
new_secret_keys = set(auth_credentials) - {"api_key_header"}
if moved and auth_type in _MCP_CREDENTIAL_AUTH_TYPES and not new_secret_keys:
+27 -12
View File
@@ -193,7 +193,7 @@ _META_KEYS = ("name", "displayName", "customName", "description", "actions")
class CredentialsRequired(Exception):
"""A save moved a tool to a new host without supplying new secrets."""
"""A save moved a tool to a new origin without supplying new secrets."""
def denied_response(err: AccessDenied):
@@ -207,12 +207,26 @@ def check_action(ra: ResourceAccess, action: str) -> None:
raise AccessDenied(403, _FORBIDDEN_MESSAGE)
def url_host(url: Any) -> str:
"""Lower-cased host of ``url`` ('' when it has none)."""
_DEFAULT_PORTS = {"http": 80, "https": 443, "ws": 80, "wss": 443}
def url_origin(url: Any) -> str:
"""``scheme://host:port`` of ``url``, lower-cased, default port filled in.
Saved secrets follow the origin, not just the host: the same host over
``http`` would send them in cleartext, and another port can be another
service. ``''`` when the URL has no host or doesn't parse.
"""
try:
return (urlparse(str(url or "").strip()).hostname or "").lower()
parts = urlparse(str(url or "").strip())
host = (parts.hostname or "").lower()
port = parts.port
except ValueError:
return ""
if not host:
return ""
scheme = (parts.scheme or "").lower()
return f"{scheme}://{host}:{port or _DEFAULT_PORTS.get(scheme, '')}"
def _has_value(value: Any) -> bool:
@@ -270,7 +284,7 @@ def _seal_api_tool_secrets(new_config: dict, existing_config: dict, owner_id: st
An incoming entry with a value replaces the stored one; an empty value with
``has_value`` keeps it (legacy plaintext values included); anything else
clears it. When an action's URL host changes the stored values are not
clears it. When an action's URL origin changes the stored values are not
carried over.
Args:
@@ -282,7 +296,7 @@ def _seal_api_tool_secrets(new_config: dict, existing_config: dict, owner_id: st
The config to persist.
Raises:
CredentialsRequired: a host changed, the client asked to keep a value,
CredentialsRequired: an origin changed, the client asked to keep a value,
and there is nothing to keep.
"""
existing_config = existing_config or {}
@@ -293,7 +307,7 @@ def _seal_api_tool_secrets(new_config: dict, existing_config: dict, owner_id: st
sealed: dict = {}
for name, action in (out.get("actions") or {}).items():
old = old_actions.get(name) if isinstance(old_actions, dict) else None
moved = isinstance(old, dict) and url_host(old.get("url")) != url_host(
moved = isinstance(old, dict) and url_origin(old.get("url")) != url_origin(
action.get("url") if isinstance(action, dict) else ""
)
prior = {} if moved else stored.get(name, {})
@@ -345,9 +359,10 @@ def _api_tool_config_needs_credentials(new_config: dict, existing_config: dict)
return False
def _mcp_host_changed(new_config: dict, existing_config: dict) -> bool:
def _mcp_origin_changed(new_config: dict, existing_config: dict) -> bool:
"""Whether a save moves an MCP server to another scheme, host or port."""
old_url = (existing_config or {}).get("server_url")
return bool(old_url) and url_host(old_url) != url_host((new_config or {}).get("server_url"))
return bool(old_url) and url_origin(old_url) != url_origin((new_config or {}).get("server_url"))
SHARED_OAUTH_OWNER_ONLY = "Only the owner can change or reconnect this server"
@@ -386,16 +401,16 @@ def _prepare_tool_config(tool_doc: dict, new_config: dict, config_requirements:
Handles the three secret stores: ``config_requirements`` secrets
(``encrypted_credentials``), api_tool header/query values, and the MCP
host-change rule (a new server host drops stored credentials).
origin-change rule (a new scheme, host or port drops stored credentials).
Raises:
CredentialsRequired: the MCP host changed and no new secret arrived.
CredentialsRequired: the MCP origin changed and no new secret arrived.
"""
owner_id = tool_doc["user_id"]
existing_config = tool_doc.get("config") or {}
if tool_doc.get("name") == "api_tool":
return _seal_api_tool_secrets(new_config, existing_config, owner_id)
moved = tool_doc.get("name") == "mcp_tool" and _mcp_host_changed(new_config, existing_config)
moved = tool_doc.get("name") == "mcp_tool" and _mcp_origin_changed(new_config, existing_config)
if moved:
existing_config = {k: v for k, v in existing_config.items() if k != "encrypted_credentials"}
final = _merge_secrets_on_update(new_config, existing_config, config_requirements, owner_id)