Roles audit and revamp

This commit is contained in:
Pavel committed 2026-09-29 10:42:39 +04:00
1 parent 208d34abc7
commit 98afa5d93c
134 files changed
+13725 -1702

No files matched your search

+157 -90
View File
@@ -7,7 +7,15 @@ from flask_restx import Namespace, Resource, fields
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_host_changed,
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
@@ -75,12 +83,60 @@ 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.
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.
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).
"""
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 {}
moved = _mcp_host_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)
existing_encrypted = None if moved else existing_config.get("encrypted_credentials")
if existing_encrypted:
credentials = {**decrypt_credentials(existing_encrypted, ra.owner_id), **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"
),
@@ -111,11 +167,20 @@ class TestMCPServerConfig(Resource):
_validate_mcp_server_url(config)
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
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
)
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"):
@@ -148,6 +213,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(
@@ -207,13 +274,55 @@ class MCPServerSave(Resource):
_validate_mcp_server_url(config)
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
existing_config = (existing_doc or {}).get("config") or {}
auth_type = config.get("auth_type", "none")
mcp_config = config.copy()
mcp_config["auth_credentials"] = auth_credentials
mcp_config["auth_credentials"] = merged_credentials
keep_actions = False
if auth_type == "oauth":
if not config.get("oauth_task_id"):
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:
return make_response(
jsonify(
{
@@ -223,24 +332,8 @@ 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 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:
@@ -249,30 +342,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",
@@ -283,58 +356,54 @@ class MCPServerSave(Resource):
"redirect_uri",
]:
storage_config.pop(field, None)
transformed_actions = transform_actions(actions_metadata)
# Kept actions already carry the owner's on/off and approval flags.
transformed_actions = actions_metadata if keep_actions else transform_actions(actions_metadata)
display_name = data["displayName"]
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,
"actions": transformed_actions,
}
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,
"actions": transformed_actions,
"status": status_bool,
},
)
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
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": transformed_actions,
"status": status_bool,
},
)
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:
@@ -355,18 +424,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(
@@ -455,6 +515,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(
@@ -472,7 +539,7 @@ class MCPAuthStatus(Resource):
if server_url:
parsed = urlparse(server_url)
base_url = f"{parsed.scheme}://{parsed.netloc}"
oauth_server_urls[tool_id] = base_url
oauth_server_urls[tool_id] = (tool.get("user_id") or user, base_url)
else:
statuses[tool_id] = "needs_auth"
else:
@@ -484,9 +551,9 @@ class MCPAuthStatus(Resource):
# and the URL in ``server_url``; reuse the repo's
# per-URL accessor rather than an ad-hoc $in query.
url_has_tokens: dict = {}
for base_url in set(oauth_server_urls.values()):
for owner_id, base_url in set(oauth_server_urls.values()):
session = sessions_repo.get_by_user_and_server_url(
user, base_url,
owner_id, base_url,
)
tokens = (
(session or {}).get("session_data", {}) or {}
@@ -494,13 +561,13 @@ class MCPAuthStatus(Resource):
# MCP code also stashes tokens into token_info on
# the row; consider either present as "connected".
token_info = (session or {}).get("token_info") or {}
url_has_tokens[base_url] = bool(
url_has_tokens[(owner_id, base_url)] = bool(
tokens.get("access_token")
or token_info.get("access_token")
)
for tool_id, base_url in oauth_server_urls.items():
if url_has_tokens.get(base_url):
for tool_id, key in oauth_server_urls.items():
if url_has_tokens.get(key):
statuses[tool_id] = "connected"
else:
statuses[tool_id] = "needs_auth"
+356 -88
View File
@@ -1,7 +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,
@@ -13,12 +18,21 @@ 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.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.core.settings import settings
from docsgpt.core.url_validation import SSRFError, validate_url
from docsgpt.security.encryption import decrypt_credentials, encrypt_credentials
@@ -26,6 +40,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
@@ -166,6 +183,238 @@ 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 host 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)
def url_host(url: Any) -> str:
"""Lower-cased host of ``url`` ('' when it has none)."""
try:
return (urlparse(str(url or "").strip()).hostname or "").lower()
except ValueError:
return ""
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 host 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: a host 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_host(old.get("url")) != url_host(
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_host_changed(new_config: dict, existing_config: dict) -> bool:
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"))
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
host-change rule (a new server host drops stored credentials).
Raises:
CredentialsRequired: the MCP host 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)
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.
@@ -239,6 +488,10 @@ class GetTools(Resource):
team_shared = visible_with_access(conn, user, "tool")
shared_ids = [tid for tid in team_shared if tid not in owned_ids]
shared_rows = tools_repo.list_by_ids(shared_ids)
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):
@@ -257,14 +510,25 @@ class GetTools(Resource):
):
tool_copy["config"]["has_encrypted_credentials"] = True
tool_copy["config"].pop("encrypted_credentials", None)
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
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-
@@ -275,6 +539,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"]))
@@ -386,9 +651,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,
@@ -478,43 +746,47 @@ 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 == "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
if config_requirements:
validation_errors = _validate_config(
data["config"], config_requirements,
@@ -529,29 +801,27 @@ class UpdateTool(Resource):
}),
400,
)
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)
@@ -596,7 +866,8 @@ 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)
@@ -633,11 +904,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
@@ -684,20 +957,12 @@ class UpdateToolActions(Resource):
)
try:
with db_session() as conn:
repo = UserToolsRepository(conn)
tool_doc = repo.get_any(data["id"], user)
if tool_doc:
repo.update(str(tool_doc["id"]), user, {"actions": data["actions"]})
else:
# 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)
if not owner:
return make_response(
jsonify({"success": False, "message": "Tool not found"}),
404,
)
repo.update(data["id"], owner, {"actions": data["actions"]})
# ``edit`` covers action on/off, descriptions and approval
# (``require_approval``); actions carry no credentials.
ra = require(conn, "tool", data["id"], user, "edit")
UserToolsRepository(conn).update(ra.resource_id, ra.owner_id, {"actions": data["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
@@ -753,16 +1018,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
@@ -802,13 +1070,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)