Merge main (roles and access revamp) into connectors

Main's access model (team editors and viewers, edit_credentials, owner-only
OAuth servers, per-user tool preferences, resource sponsors) now applies to
connection-backed tools and sources. Our migrations are renumbered to
0040_connections and 0041_connection_account_name, after main's
0038_resource_access_settings and 0039_resource_sponsors.

Where the two sides met: MCP tools save and load their connections as the
tool owner, editors may add a key (a new connection on the owner's account)
but never rewrite an existing connection's secret, fixed values stay
owner-only, and a member's own connection is used in member mode.
This commit is contained in:
arc53-machine committed 2026-09-29 14:02:26 +01:00
commit a1789d2ab4
167 files changed
+16909 -2126

No files matched your search

+163 -77
View File
@@ -8,7 +8,16 @@ from flask_restx import Namespace, Resource, fields
from docsgpt.agents.tool_pins import carry_pins_between
from docsgpt.agents.tools.mcp_tool import MCPOAuthManager, MCPTool
from docsgpt.api import api
from docsgpt.api.user.tools.routes import transform_actions
from docsgpt.api.user.resource_access import AccessDenied, require
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_origin_changed,
check_oauth_mcp_owner_only,
denied_response,
transform_actions,
)
from docsgpt.cache import get_redis_instance
from docsgpt.core.url_validation import SSRFError, validate_url
from docsgpt.security.encryption import decrypt_credentials, encrypt_credentials
@@ -116,13 +125,14 @@ def _mcp_connection(user, config, auth_type, auth_credentials, display_name):
return str(row["id"]) if row else None
def _previous_connection(existing_doc, config, user) -> str | None:
def _previous_connection(existing_doc, config, owner_id) -> str | None:
"""The saved tool's connection, kept only while the server is unchanged.
An edit that cannot resolve a connection of its own (the default
encryption key blocks storing a new secret) must not carry the old
one over to a different server (its key would be sent there), to a
connection the caller does not own, or to one that signs in another way.
connection the tool's owner does not own, or to one that signs in
another way.
"""
from docsgpt.connectors import catalog
@@ -131,7 +141,7 @@ def _previous_connection(existing_doc, config, user) -> str | None:
if not connection_id or not base:
return None
with db_readonly() as conn:
row = ConnectorSessionsRepository(conn).get_for_user(str(connection_id), user)
row = ConnectorSessionsRepository(conn).get_for_user(str(connection_id), owner_id)
if row is None or catalog.base_url(row.get("server_url")) != base:
return None
wanted = {"oauth": "mcp_oauth", "none": "none"}.get(config.get("auth_type") or "none", "api_key")
@@ -168,12 +178,97 @@ def _mcp_policy_error(config: dict):
return None
def _stored_mcp_credentials(existing_doc: dict, owner_id: str) -> dict:
"""The secrets a saved MCP tool authenticates with, for reuse on an edit.
A legacy tool keeps them encrypted in its config; a connection-backed one
on its key-based connection, read only while that connection is the
owner's and was stored for the tool's own server.
Args:
existing_doc: The stored ``user_tools`` row.
owner_id: The tool's owner.
Returns:
The stored credentials, or ``{}`` when there are none to reuse.
"""
from docsgpt.connectors import catalog, service
existing_config = existing_doc.get("config") or {}
if existing_config.get("encrypted_credentials"):
return decrypt_credentials(existing_config["encrypted_credentials"], owner_id)
connection_id = existing_doc.get("connection_id")
if not connection_id:
return {}
with db_readonly() as conn:
row = ConnectorSessionsRepository(conn).get_for_user(str(connection_id), owner_id)
if (
row is None
or (row.get("auth_kind") or "") != "api_key"
or catalog.base_url(row.get("server_url")) != catalog.base_url(existing_config.get("server_url"))
):
return {}
try:
return service.get_credentials(row)
except service.ConnectionUnavailable:
return {}
def _existing_mcp_context(tool_id, user, config):
"""Resolve the stored MCP tool a test/save refers to, and its credentials.
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 (on the tool, or on its key-based connection) 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).
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), 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:
return None, user, True, False, auth_credentials
with db_readonly() as conn:
ra = require(conn, "tool", tool_id, user, "edit_credentials")
existing_doc = UserToolsRepository(conn).get_any(ra.resource_id, ra.owner_id)
if not existing_doc or existing_doc.get("name") != "mcp_tool":
return make_response(
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_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:
return make_response(
jsonify({"success": False, "message": _CREDENTIALS_FOR_NEW_SERVER}), 400
)
credentials = dict(auth_credentials)
stored = {} if moved else _stored_mcp_credentials(existing_doc, ra.owner_id)
if stored:
credentials = {**stored, **auth_credentials}
return existing_doc, ra.owner_id, ra.access == "owner", moved, credentials
@tools_mcp_ns.route("/mcp_server/test")
class TestMCPServerConfig(Resource):
@api.expect(
api.model(
"MCPServerTestModel",
{
"id": fields.String(
required=False,
description="Stored tool to test with (empty secrets reuse its stored ones)",
),
"config": fields.Raw(
required=True, description="MCP server configuration to test"
),
@@ -207,11 +302,14 @@ class TestMCPServerConfig(Resource):
if policy_error is not None:
return policy_error
auth_credentials = _extract_auth_credentials(config)
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
test_config = config.copy()
test_config["auth_credentials"] = auth_credentials
mcp_tool = MCPTool(config=test_config, user_id=user)
mcp_tool = MCPTool(config=test_config, user_id=owner_id)
result = mcp_tool.test_connection()
if result.get("requires_oauth"):
@@ -244,6 +342,8 @@ class TestMCPServerConfig(Resource):
"tools": result.get("tools", []),
}
return make_response(jsonify(safe_result), 200)
except AccessDenied as e:
return denied_response(e)
except ValueError as e:
current_app.logger.warning(f"Invalid MCP server test request: {e}")
return make_response(
@@ -306,16 +406,23 @@ class MCPServerSave(Resource):
if policy_error is not None:
return policy_error
auth_credentials = _extract_auth_credentials(config)
# An existing id is always an update of THAT row, written as its
# owner; it never falls through to creating a copy for the caller.
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
auth_type = config.get("auth_type", "none")
mcp_config = config.copy()
mcp_config["auth_credentials"] = auth_credentials
mcp_config["auth_credentials"] = merged_credentials
if auth_type == "oauth" and not config.get("oauth_task_id"):
# No new handshake: the caller signed in to this server
# before, so its stored tokens answer the discovery.
# Only the owner reaches here for an existing server (see
# ``_existing_mcp_context``). No new handshake: they signed in
# to this server before, so its stored tokens answer the
# discovery, and the save below still needs that connection.
try:
mcp_tool = MCPTool(config=mcp_config, user_id=user)
mcp_tool = MCPTool(config=mcp_config, user_id=owner_id)
mcp_tool.discover_tools()
actions_metadata = mcp_tool.get_actions_metadata()
except Exception:
@@ -345,8 +452,8 @@ class MCPServerSave(Resource):
400,
)
actions_metadata = result.get("tools", [])
elif auth_type == "none" or auth_credentials:
mcp_tool = MCPTool(config=mcp_config, user_id=user)
elif auth_type == "none" or merged_credentials:
mcp_tool = MCPTool(config=mcp_config, user_id=owner_id)
mcp_tool.discover_tools()
actions_metadata = mcp_tool.get_actions_metadata()
else:
@@ -355,30 +462,10 @@ class MCPServerSave(Resource):
)
storage_config = config.copy()
tool_id = data.get("id")
existing_doc = None
existing_encrypted = None
if tool_id:
with db_readonly() as conn:
repo = UserToolsRepository(conn)
existing_doc = repo.get_any(tool_id, user)
if existing_doc and existing_doc.get("name") == "mcp_tool":
existing_encrypted = (existing_doc.get("config") or {}).get(
"encrypted_credentials"
)
else:
existing_doc = None
if auth_credentials:
if existing_encrypted:
existing_secrets = decrypt_credentials(existing_encrypted, user)
existing_secrets.update(auth_credentials)
auth_credentials = existing_secrets
if merged_credentials:
storage_config["encrypted_credentials"] = encrypt_credentials(
auth_credentials, user
merged_credentials, owner_id
)
elif existing_encrypted:
storage_config["encrypted_credentials"] = existing_encrypted
for field in [
"api_key",
@@ -396,9 +483,11 @@ class MCPServerSave(Resource):
)
display_name = data["displayName"]
# The connection is the owner's: an editor's save stores the
# secret on the owner's account, as the tool runs as its owner.
connection_id = _mcp_connection(
user, storage_config, auth_type, auth_credentials, display_name,
) or _previous_connection(existing_doc, storage_config, user)
owner_id, storage_config, auth_type, merged_credentials, display_name,
) or _previous_connection(existing_doc, storage_config, owner_id)
if auth_type == "oauth" and not connection_id:
# A sign-in server's tokens live on its connection. Without one
# (it was removed, and a client cached before that answered the
@@ -415,56 +504,53 @@ class MCPServerSave(Resource):
storage_config.pop("encrypted_credentials", None)
description = f"MCP Server: {storage_config.get('server_url', 'Unknown')}"
status_bool = bool(data.get("status", True))
fields_out = {
"display_name": display_name,
"custom_name": display_name,
"description": description,
"config": storage_config,
"connection_id": connection_id,
}
updated_message = (
f"MCP server updated successfully! Discovered {len(transformed_actions)} tools."
)
with db_session() as conn:
repo = UserToolsRepository(conn)
if existing_doc:
repo.update(
str(existing_doc["id"]), user,
{
"display_name": display_name,
"custom_name": display_name,
"description": description,
"config": storage_config,
# Fixed values the user set survive a re-save.
"actions": carry_pins_between(existing_doc.get("actions"), transformed_actions),
"status": status_bool,
"connection_id": connection_id,
},
)
if existing_doc is not None:
# ``status`` is the owner's own chat switch; an editor's
# save doesn't flip it.
if is_owner:
fields_out["status"] = status_bool
# Fixed values the owner set survive a re-save.
fields_out["actions"] = carry_pins_between(existing_doc.get("actions"), transformed_actions)
repo.update(str(existing_doc["id"]), owner_id, fields_out)
saved_id = str(existing_doc["id"])
response_data = {
"success": True,
"id": saved_id,
"message": f"MCP server updated successfully! Discovered {len(transformed_actions)} tools.",
"message": updated_message,
"tools_count": len(transformed_actions),
}
else:
fields_out["status"] = status_bool
# Fall back to find_by_user_and_name — the original
# dual-write path also ran an existence check before
# deciding between insert and update.
existing_by_name = repo.find_by_user_and_name(user, "mcp_tool")
if tool_id is None and existing_by_name and (
if existing_by_name and (
(existing_by_name.get("config") or {}).get("server_url")
== storage_config.get("server_url")
):
repo.update(
str(existing_by_name["id"]), user,
{
"display_name": display_name,
"custom_name": display_name,
"description": description,
"config": storage_config,
"actions": carry_pins_between(existing_by_name.get("actions"), transformed_actions),
"status": status_bool,
"connection_id": connection_id,
},
fields_out["actions"] = carry_pins_between(
existing_by_name.get("actions"), transformed_actions,
)
repo.update(str(existing_by_name["id"]), user, fields_out)
saved_id = str(existing_by_name["id"])
response_data = {
"success": True,
"id": saved_id,
"message": f"MCP server updated successfully! Discovered {len(transformed_actions)} tools.",
"message": updated_message,
"tools_count": len(transformed_actions),
}
else:
@@ -486,18 +572,9 @@ class MCPServerSave(Resource):
"message": f"MCP server created successfully! Discovered {len(transformed_actions)} tools.",
"tools_count": len(transformed_actions),
}
if tool_id and existing_doc is None:
# Client requested update on a non-existent tool id.
return make_response(
jsonify(
{
"success": False,
"error": "Tool not found or access denied",
}
),
404,
)
return make_response(jsonify(response_data), 200)
except AccessDenied as e:
return denied_response(e)
except ValueError as e:
current_app.logger.warning(f"Invalid MCP server save request: {e}")
return make_response(
@@ -586,6 +663,13 @@ class MCPAuthStatus(Resource):
tools_repo = UserToolsRepository(conn)
sessions_repo = ConnectorSessionsRepository(conn)
all_tools = tools_repo.list_for_user(user)
owned_ids = {str(t["id"]) for t in all_tools}
# Team-shared MCP servers the caller can see run with the
# owner's connection, so their status is the owner's.
shared_ids = [
tid for tid in visible_with_access(conn, user, "tool") if tid not in owned_ids
]
all_tools = all_tools + tools_repo.list_by_ids(shared_ids)
mcp_tools = [t for t in all_tools if t.get("name") == "mcp_tool"]
if not mcp_tools:
return make_response(
@@ -606,8 +690,10 @@ class MCPAuthStatus(Resource):
row = sessions_repo.get(str(tool["connection_id"]))
elif auth_type == "oauth" and config.get("server_url"):
parsed = urlparse(config["server_url"])
# A team-shared server signs in as its owner.
row = sessions_repo.get_by_user_provider(
user, service.mcp_provider(f"{parsed.scheme}://{parsed.netloc}"),
tool.get("user_id") or user,
service.mcp_provider(f"{parsed.scheme}://{parsed.netloc}"),
)
if row is not None:
connected = service.normalize_status(row) == service.STATUS_CONNECTED
+412 -87
View File
@@ -1,9 +1,12 @@
"""Tool management routes."""
import copy
from typing import Any, Optional
from urllib.parse import urlparse
from flask import current_app, jsonify, make_response, request
from flask_restx import fields, Namespace, Resource
from sqlalchemy import Connection, text
from docsgpt.agents.default_tools import (
builtin_agent_tools_for_management,
@@ -15,13 +18,22 @@ from docsgpt.agents.default_tools import (
is_synthesized_tool_id,
WORKFLOW_ONLY_BUILTINS,
)
from docsgpt.agents.tool_executor import API_TOOL_SECRET_SECTIONS, API_TOOL_SECRETS_KEY
from docsgpt.agents.tool_pins import merge_submitted_actions, PinChangeRefused
from docsgpt.agents.tools.spec_parser import parse_spec
from docsgpt.agents.tools.tool_manager import ToolManager
from docsgpt.api import api
from docsgpt.api.pat.rules import filter_listing
from docsgpt.api.user.artifacts.authz import Principal, authorize_artifact
from docsgpt.api.user.team_sharing import effective_write_owner, visible_with_access
from docsgpt.api.user.resource_access import (
AccessDenied,
delete_settings,
payload_for,
require,
ResourceAccess,
settings_many,
)
from docsgpt.api.user.team_sharing import visible_with_access
from docsgpt.connectors.catalog import definition_for_tool
from docsgpt.connectors.service import account_tool_names
from docsgpt.core.settings import settings
@@ -31,6 +43,9 @@ from docsgpt.storage.db.base_repository import looks_like_uuid
from docsgpt.storage.db.repositories.artifacts import ArtifactsRepository
from docsgpt.storage.db.repositories.notes import NotesRepository
from docsgpt.storage.db.repositories.todos import TodosRepository
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
from docsgpt.storage.db.session import db_readonly, db_session
@@ -171,6 +186,284 @@ def _merge_secrets_on_update(new_config, existing_config, config_requirements, u
return storage_config
# ---------------------------------------------------------------------------
# Access + secrets helpers
# ---------------------------------------------------------------------------
_CREDENTIALS_FOR_NEW_SERVER = "Enter credentials for the new server"
_FORBIDDEN_MESSAGE = "Your access to this item doesn't allow that"
_MCP_CREDENTIAL_AUTH_TYPES = {"api_key", "bearer", "basic"}
_META_KEYS = ("name", "displayName", "customName", "description", "actions")
class CredentialsRequired(Exception):
"""A save moved a tool to a new origin without supplying new secrets."""
def denied_response(err: AccessDenied):
"""JSON response for an :class:`AccessDenied` (403 or 404)."""
return make_response(jsonify({"success": False, "message": err.message}), err.status)
def check_action(ra: ResourceAccess, action: str) -> None:
"""Raise a 403 :class:`AccessDenied` unless ``ra`` allows ``action``."""
if not ra.can(action):
raise AccessDenied(403, _FORBIDDEN_MESSAGE)
_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:
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:
return value is not None and value != ""
def _secret_props(action: Any):
"""Yield ``(section, param, spec)`` for an api_tool action's secret-bearing params."""
if not isinstance(action, dict):
return
for section in API_TOOL_SECRET_SECTIONS:
block = action.get(section)
props = block.get("properties") if isinstance(block, dict) else None
if not isinstance(props, dict):
continue
for param, spec in props.items():
if isinstance(spec, dict):
yield section, param, spec
def _stored_api_tool_secrets(config: dict, owner_id: str) -> dict:
"""Decrypted ``{action: {section: {param: value}}}`` plus legacy plaintext values."""
config = config or {}
blob = config.get(API_TOOL_SECRETS_KEY)
secrets: dict = decrypt_credentials(blob, owner_id) if blob else {}
for name, action in (config.get("actions") or {}).items():
for section, param, spec in _secret_props(action):
value = spec.get("value")
if _has_value(value):
secrets.setdefault(name, {}).setdefault(section, {}).setdefault(param, value)
return secrets
def mask_api_tool_config(config: dict) -> dict:
"""Copy of an api_tool config with header/query values blanked and ``has_value`` set.
Args:
config: The stored ``user_tools.config``.
Returns:
A deep copy safe to return to any caller: the encrypted blob is dropped
and every header / query-param entry has ``value: ""`` plus ``has_value``.
"""
out = copy.deepcopy(config or {})
out.pop(API_TOOL_SECRETS_KEY, None)
for action in (out.get("actions") or {}).values():
for _section, _param, spec in _secret_props(action):
spec["has_value"] = _has_value(spec.get("value")) or bool(spec.get("has_value"))
spec["value"] = ""
return out
def _seal_api_tool_secrets(new_config: dict, existing_config: dict, owner_id: str) -> dict:
"""Move api_tool header/query values into an encrypted blob keyed by the owner.
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 origin changes the stored values are not
carried over.
Args:
new_config: The config the client sent.
existing_config: The stored config (``{}`` on create).
owner_id: The tool row's ``user_id`` — the encryption key owner.
Returns:
The config to persist.
Raises:
CredentialsRequired: an origin changed, the client asked to keep a value,
and there is nothing to keep.
"""
existing_config = existing_config or {}
stored = _stored_api_tool_secrets(existing_config, owner_id)
old_actions = existing_config.get("actions") or {}
out = copy.deepcopy(new_config or {})
out.pop(API_TOOL_SECRETS_KEY, None)
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_origin(old.get("url")) != url_origin(
action.get("url") if isinstance(action, dict) else ""
)
prior = {} if moved else stored.get(name, {})
for section, param, spec in _secret_props(action):
value = spec.get("value")
if _has_value(value):
kept = value
elif spec.get("has_value"):
kept = (prior.get(section) or {}).get(param)
if not _has_value(kept):
if moved:
raise CredentialsRequired(_CREDENTIALS_FOR_NEW_SERVER)
kept = None
else:
kept = None
spec["value"] = ""
spec["has_value"] = kept is not None
if kept is not None:
sealed.setdefault(name, {}).setdefault(section, {})[param] = kept
if sealed:
out[API_TOOL_SECRETS_KEY] = encrypt_credentials(sealed, owner_id)
return out
def _api_tool_config_needs_credentials(new_config: dict, existing_config: dict) -> bool:
"""Whether an api_tool config change touches endpoints or secrets.
Descriptions, parameter schemas and on/off flags are ``edit``; a new or
changed URL, a new action (it brings a URL), a secret value or any other
config key is ``edit_credentials``.
"""
new_config = new_config or {}
existing_config = existing_config or {}
ignore = ("actions", API_TOOL_SECRETS_KEY, "has_encrypted_credentials")
if {k: v for k, v in new_config.items() if k not in ignore} != {
k: v for k, v in existing_config.items() if k not in ignore
}:
return True
old_actions = existing_config.get("actions") or {}
for name, action in (new_config.get("actions") or {}).items():
old = old_actions.get(name)
if not isinstance(old, dict) or not isinstance(action, dict):
return True
if str(action.get("url") or "") != str(old.get("url") or ""):
return True
for _section, _param, spec in _secret_props(action):
if _has_value(spec.get("value")):
return True
return False
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_origin(old_url) != url_origin((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.
Handles the three secret stores: ``config_requirements`` secrets
(``encrypted_credentials``), api_tool header/query values, and the MCP
origin-change rule (a new scheme, host or port drops stored credentials).
Raises:
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_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)
if moved and final.get("auth_type") in _MCP_CREDENTIAL_AUTH_TYPES and not final.get(
"encrypted_credentials"
):
raise CredentialsRequired(_CREDENTIALS_FOR_NEW_SERVER)
return final
def _shared_via(conn: Connection, user_id: str, tool_ids: list) -> dict:
"""``tool_id -> team name`` through which a grant reaches ``user_id``."""
ids = [str(t) for t in tool_ids if looks_like_uuid(str(t))]
if not ids:
return {}
rows = conn.execute(
text(
"""
SELECT DISTINCT ON (g.resource_id) g.resource_id, t.name
FROM team_resource_grants g
JOIN team_members m ON m.team_id = g.team_id
JOIN teams t ON t.id = g.team_id
WHERE m.user_id = :user_id AND g.resource_type = 'tool'
AND g.resource_id = ANY(CAST(:ids AS uuid[]))
AND (g.target_user_id IS NULL OR g.target_user_id = :user_id)
ORDER BY g.resource_id, (g.access_level = 'editor') DESC, t.name
"""
),
{"user_id": user_id, "ids": ids},
).fetchall()
return {str(r[0]): r[1] for r in rows}
def _owner_labels(conn: Connection, owner_ids) -> dict:
"""``user_id -> email`` for the owners that have one on record."""
ids = sorted({str(o) for o in owner_ids if o})
if not ids:
return {}
rows = conn.execute(
text("SELECT user_id, email FROM users WHERE user_id = ANY(:ids) AND email IS NOT NULL"),
{"ids": ids},
).fetchall()
return {r[0]: r[1] for r in rows}
def _load_owned_row(conn: Connection, ra: ResourceAccess) -> Optional[dict]:
"""The tool row behind ``ra``, read as its owner."""
return UserToolsRepository(conn).get_any(ra.resource_id, ra.owner_id)
def transform_actions(actions_metadata):
"""Set default flags on action metadata for storage.
@@ -293,6 +586,10 @@ class GetTools(Resource):
shared_rows = tools_repo.list_by_ids(shared_ids)
# "Telegram · Alerts bot" when the owner has several bots.
account_names = account_tool_names(conn, [*rows, *shared_rows])
switches = settings_many(conn, "tool", [*owned_ids, *shared_ids])
prefs = UserToolPreferencesRepository(conn).in_chat_many(user, shared_ids)
shared_via = _shared_via(conn, user, shared_ids)
owner_labels = _owner_labels(conn, [r.get("user_id") for r in shared_rows])
user_tools = []
def _shape_tool(row, *, ownership="user", force_strip_secret=False):
@@ -315,16 +612,27 @@ class GetTools(Resource):
# The secret lives on the connection; the form must not
# ask for it again.
tool_copy.setdefault("config", {})["has_encrypted_credentials"] = True
if tool_copy.get("name") == "api_tool":
# Header / query-param values are secrets for everyone.
tool_copy["config"] = mask_api_tool_config(tool_copy.get("config") or {})
tool_copy["ownership"] = ownership
if str(row["id"]) in account_names:
tool_copy["customName"] = tool_copy["displayName"] = account_names[str(row["id"])]
return tool_copy
for row in rows:
user_tools.append(_shape_tool(row))
shaped = _shape_tool(row)
shaped.update(payload_for("tool", "owner", switches.get(str(row["id"]))))
shaped["in_chat"] = bool(row.get("status"))
user_tools.append(shaped)
for row in shared_rows:
tid = str(row["id"])
shaped = _shape_tool(row, ownership="team", force_strip_secret=True)
shaped["team_access"] = team_shared.get(str(row["id"]))
shaped["team_access"] = team_shared.get(tid)
shaped.update(payload_for("tool", team_shared.get(tid), switches.get(tid)))
shaped["in_chat"] = prefs.get(tid, False)
shaped["shared_via"] = shared_via.get(tid)
shaped["owner_label"] = owner_labels.get(row.get("user_id"))
user_tools.append(shaped)
# ``scheduler`` is dual-registered (default chat tool + agent-
@@ -335,6 +643,7 @@ class GetTools(Resource):
for default_row in default_tools_for_management(user_doc):
default_copy = _row_to_api(default_row)
default_copy["default"] = True
default_copy["in_chat"] = bool(default_copy.get("status"))
if default_copy.get("name") in BUILTIN_AGENT_TOOLS:
default_copy["builtin"] = True
seen_ids.add(str(default_copy["id"]))
@@ -451,9 +760,12 @@ class CreateTool(Resource):
),
400,
)
storage_config = _encrypt_secret_fields(
data["config"], config_requirements, user
)
if data["name"] == "api_tool":
storage_config = _seal_api_tool_secrets(data["config"], {}, user)
else:
storage_config = _encrypt_secret_fields(
data["config"], config_requirements, user
)
with db_session() as conn:
created = UserToolsRepository(conn).create(
user,
@@ -619,41 +931,48 @@ class UpdateTool(Resource):
),
400,
)
if "config" in data and isinstance(data["config"], dict) and "actions" in data["config"]:
for action_name in list((data["config"]["actions"] or {}).keys()):
if not validate_function_name(action_name):
return make_response(
jsonify(
{
"success": False,
"message": f"Invalid function name '{action_name}'. Function names must match pattern '^[a-zA-Z0-9_-]+$'.",
"param": "tools[].function.name",
}
),
400,
)
try:
update_data: dict = {}
for key in ("name", "displayName", "customName", "description", "actions"):
for key in _META_KEYS:
if key in data:
update_data[key] = data[key]
if "config" in data:
if "actions" in data["config"]:
for action_name in list(data["config"]["actions"].keys()):
if not validate_function_name(action_name):
return make_response(
jsonify(
{
"success": False,
"message": f"Invalid function name '{action_name}'. Function names must match pattern '^[a-zA-Z0-9_-]+$'.",
"param": "tools[].function.name",
}
),
400,
)
with db_session() as conn:
repo = UserToolsRepository(conn)
tool_doc = repo.get_any(data["id"], user)
if not tool_doc:
return make_response(
jsonify({"success": False, "message": "Tool not found"}),
404,
)
with db_session() as conn:
ra = require(conn, "tool", data["id"], user, "use")
tool_doc = _load_owned_row(conn, ra)
if not tool_doc:
return make_response(
jsonify({"success": False, "message": "Tool not found"}), 404,
)
if update_data:
check_action(ra, "edit")
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
):
check_action(ra, "edit")
else:
check_action(ra, "edit_credentials")
tool_instance = tool_manager.tools.get(tool_name)
config_requirements = (
tool_instance.get_config_requirements()
if tool_instance
else {}
tool_instance.get_config_requirements() if tool_instance else {}
)
existing_config = tool_doc.get("config", {}) or {}
has_existing_secrets = (
"encrypted_credentials" in existing_config or bool(tool_doc.get("connection_id"))
)
@@ -674,6 +993,9 @@ class UpdateTool(Resource):
400,
)
if tool_doc.get("connection_id"):
# The connection may back the owner's other tools too,
# so its secret stays the owner's to change even when
# an editor may change the tool's own credentials.
new_config = _update_connection_secrets(
conn, user, tool_doc, data["config"], config_requirements,
)
@@ -684,28 +1006,27 @@ class UpdateTool(Resource):
)
data = {**data, "config": new_config}
update_data["config"] = _merge_secrets_on_update(
data["config"], existing_config, config_requirements, user
update_data["config"] = _prepare_tool_config(
tool_doc, data["config"], config_requirements
)
if "status" in data:
update_data["status"] = bool(data["status"])
repo.update(
str(tool_doc["id"]), user, _api_to_update_fields(update_data),
)
else:
if "status" in data:
update_data["status"] = bool(data["status"])
with db_session() as conn:
repo = UserToolsRepository(conn)
tool_doc = repo.get_any(data["id"], user)
if not tool_doc:
return make_response(
jsonify({"success": False, "message": "Tool not found"}),
404,
if ra.access == "owner":
update_data["status"] = bool(data["status"])
else:
# A grantee's chat switch is personal; the owner's
# ``status`` is the owner's own chat setting.
check_action(ra, "use_in_own")
UserToolPreferencesRepository(conn).set_in_chat(
user, str(tool_doc["id"]), bool(data["status"])
)
repo.update(
str(tool_doc["id"]), user, _api_to_update_fields(update_data),
if update_data:
UserToolsRepository(conn).update(
str(tool_doc["id"]), ra.owner_id, _api_to_update_fields(update_data),
)
except AccessDenied as err:
return denied_response(err)
except CredentialsRequired as err:
return make_response(jsonify({"success": False, "message": str(err)}), 400)
except Exception as err:
current_app.logger.error(f"Error updating tool: {err}", exc_info=True)
return make_response(jsonify({"success": False}), 400)
@@ -750,12 +1071,14 @@ class UpdateToolConfig(Resource):
try:
with db_session() as conn:
repo = UserToolsRepository(conn)
tool_doc = repo.get_any(data["id"], user)
ra = require(conn, "tool", data["id"], user, "edit_credentials")
tool_doc = _load_owned_row(conn, ra)
if not tool_doc:
return make_response(jsonify({"success": False}), 404)
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:
@@ -787,11 +1110,13 @@ class UpdateToolConfig(Resource):
400,
)
final_config = _merge_secrets_on_update(
data["config"], existing_config, config_requirements, user
)
final_config = _prepare_tool_config(tool_doc, data["config"], config_requirements)
repo.update(str(tool_doc["id"]), user, {"config": final_config})
repo.update(str(tool_doc["id"]), ra.owner_id, {"config": final_config})
except AccessDenied as err:
return denied_response(err)
except CredentialsRequired as err:
return make_response(jsonify({"success": False, "message": str(err)}), 400)
except Exception as err:
current_app.logger.error(
f"Error updating tool config: {err}", exc_info=True
@@ -838,31 +1163,28 @@ class UpdateToolActions(Resource):
)
try:
with db_session() as conn:
repo = UserToolsRepository(conn)
tool_doc = repo.get_any(data["id"], user)
owner = user
if tool_doc is None:
# Team editor write path (secrets stay owner-only — actions
# carry no credentials, so editing them is safe).
owner = effective_write_owner(conn, "tool", data["id"], user)
tool_doc = repo.get_by_id(data["id"]) if owner else None
if not tool_doc or tool_doc.get("user_id") != owner:
return make_response(
jsonify({"success": False, "message": "Tool not found"}),
404,
)
# ``edit`` covers action on/off, descriptions and approval
# (``require_approval``); actions carry no credentials.
ra = require(conn, "tool", data["id"], user, "edit")
tool_doc = _load_owned_row(conn, ra)
if not tool_doc:
return make_response(
jsonify({"success": False, "message": "Tool not found"}), 404,
)
# The stored actions are the schema: nothing can be added,
# and a fixed value is the owner's to set (an editor pinning
# a Telegram chat would redirect the owner's bot).
try:
actions = merge_submitted_actions(
_stored_actions(tool_doc), data["actions"], may_change_pins=owner == user,
_stored_actions(tool_doc), data["actions"], may_change_pins=ra.access == "owner",
)
except PinChangeRefused as err:
return make_response(jsonify({"success": False, "message": str(err)}), 403)
except ValueError as err:
return make_response(jsonify({"success": False, "message": str(err)}), 400)
repo.update(str(tool_doc["id"]), owner, {"actions": actions})
UserToolsRepository(conn).update(str(tool_doc["id"]), ra.owner_id, {"actions": actions})
except AccessDenied as err:
return denied_response(err)
except Exception as err:
current_app.logger.error(
f"Error updating tool actions: {err}", exc_info=True
@@ -918,16 +1240,19 @@ class UpdateToolStatus(Resource):
400,
)
with db_session() as conn:
repo = UserToolsRepository(conn)
tool_doc = repo.get_any(data["id"], user)
if not tool_doc:
return make_response(
jsonify({"success": False, "message": "Tool not found"}),
404,
ra = require(conn, "tool", data["id"], user, "use")
if ra.access == "owner":
UserToolsRepository(conn).update(
ra.resource_id, ra.owner_id, {"status": bool(data["status"])},
)
repo.update(
str(tool_doc["id"]), user, {"status": bool(data["status"])},
)
else:
# A grantee's "In my chats" switch is personal.
check_action(ra, "use_in_own")
UserToolPreferencesRepository(conn).set_in_chat(
user, ra.resource_id, bool(data["status"])
)
except AccessDenied as err:
return denied_response(err)
except Exception as err:
current_app.logger.error(
f"Error updating tool status: {err}", exc_info=True
@@ -967,13 +1292,13 @@ class DeleteTool(Resource):
)
try:
with db_session() as conn:
repo = UserToolsRepository(conn)
tool_doc = repo.get_any(data["id"], user)
if not tool_doc:
return make_response(
jsonify({"success": False, "message": "Tool not found"}), 404
)
repo.delete(str(tool_doc["id"]), user)
ra = require(conn, "tool", data["id"], user, "delete")
# Grants are removed by the ``user_tools`` delete trigger and
# chat preferences by FK cascade; the switches have no FK.
UserToolsRepository(conn).delete(ra.resource_id, ra.owner_id)
delete_settings(conn, "tool", ra.resource_id)
except AccessDenied as err:
return denied_response(err)
except Exception as err:
current_app.logger.error(f"Error deleting tool: {err}", exc_info=True)
return make_response(jsonify({"success": False}), 400)