mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-15 18:15:33 +00:00
Add MCP presets, admin connector policies and connector attribution
Six vendor-run MCP servers (Notion, Linear, Atlassian, Sentry, Asana, Stripe) join the catalog as presets from docsgpt/connectors/presets/ mcp.yaml; existing connections to those servers show under them. connector_policies lets an admin turn a connector off or force whose account every share uses, and app_metadata's connectors.allow_custom_mcp turns custom MCP servers off. Both are enforced on the server: new connections, OAuth sign-ins, MCP test and save, and the tools of a disabled connector stop resolving. GET/PUT /api/admin/connectors reads and changes them. A shared tool's owner picks owner or member credentials with PUT /api/connections/tools/<id>/credential-mode, and a member running the owner's account always confirms write actions. Tool-call events and retrieved chunks name the connector they came from (key and display name, never the account), and tool calls keep those fields when the conversation is reloaded.
This commit is contained in:
1 parent
6e2ee43944
commit
b692eaf7ad
20 files changed
+844
-4
No files matched your search
@@ -1,5 +1,6 @@
|
||||
from .routes import admin_ns
|
||||
from . import quotas # noqa: F401 (registers the quota resources on admin_ns)
|
||||
from . import activity # noqa: F401 (registers the activity resources on admin_ns)
|
||||
from . import connectors # noqa: F401 (registers the connector policy resources on admin_ns)
|
||||
|
||||
__all__ = ["admin_ns"]
|
||||
@@ -0,0 +1,130 @@
|
||||
"""Admin > Connectors: turn connectors on or off and set sharing policy.
|
||||
|
||||
Every resource here is behind ``@admin_required`` (the frontend guard is
|
||||
cosmetic). The page also shows what each OAuth connector still needs from
|
||||
the server (settings and the redirect URI to register), never their values.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from flask import jsonify, make_response, request
|
||||
from flask_restx import Resource
|
||||
from sqlalchemy import text
|
||||
|
||||
from docsgpt.api.admin.routes import _actor, admin_ns
|
||||
from docsgpt.api.user.authz import admin_required
|
||||
from docsgpt.connectors import catalog, service
|
||||
from docsgpt.core.settings import settings
|
||||
from docsgpt.security.encryption import is_default_encryption_key
|
||||
from docsgpt.storage.db.repositories.app_metadata import AppMetadataRepository
|
||||
from docsgpt.storage.db.repositories.connector_policies import (
|
||||
ALLOW_CUSTOM_MCP_KEY,
|
||||
CREDENTIAL_POLICIES,
|
||||
ConnectorPoliciesRepository,
|
||||
)
|
||||
from docsgpt.storage.db.session import db_readonly, db_session
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _connection_counts(conn) -> dict[str, int]:
|
||||
rows = conn.execute(
|
||||
text(
|
||||
"SELECT connector_key, provider, server_url, count(*) AS n FROM connector_sessions "
|
||||
"WHERE COALESCE(status, '') <> 'pending' GROUP BY connector_key, provider, server_url"
|
||||
)
|
||||
).fetchall()
|
||||
counts: dict[str, int] = {}
|
||||
for row in rows:
|
||||
key = catalog.connector_key_for_row(dict(row._mapping))
|
||||
if key:
|
||||
counts[key] = counts.get(key, 0) + int(row.n)
|
||||
return counts
|
||||
|
||||
|
||||
def _mcp_redirect_uri() -> str:
|
||||
from docsgpt.agents.tools.mcp_tool import MCPTool
|
||||
|
||||
return MCPTool._resolve_redirect_uri(MCPTool.__new__(MCPTool), None)
|
||||
|
||||
|
||||
@admin_ns.route("/admin/connectors")
|
||||
class AdminConnectorsResource(Resource):
|
||||
@admin_required
|
||||
def get(self):
|
||||
"""Every connector with its policy, setup state and connection count."""
|
||||
with db_readonly() as conn:
|
||||
policies = ConnectorPoliciesRepository(conn).all()
|
||||
allow_custom = service.custom_mcp_allowed(conn)
|
||||
counts = _connection_counts(conn)
|
||||
connectors = []
|
||||
for definition in catalog.all_definitions():
|
||||
policy = policies.get(definition.key) or {}
|
||||
connectors.append(
|
||||
{
|
||||
"key": definition.key,
|
||||
"name": definition.name,
|
||||
"icon": definition.icon,
|
||||
"category": definition.category,
|
||||
"publisher": definition.publisher,
|
||||
"auth_kind": definition.auth_kind,
|
||||
"enabled": policy.get("enabled", True),
|
||||
"credential_mode": policy.get("credential_mode", "choose"),
|
||||
"configured": definition.configured,
|
||||
"required_settings": [
|
||||
{"name": name, "set": bool(getattr(settings, name, None))}
|
||||
for name in definition.required_settings
|
||||
],
|
||||
"connection_count": counts.get(definition.key, 0),
|
||||
"docs_url": definition.docs_url,
|
||||
"mcp_url": definition.mcp_url,
|
||||
}
|
||||
)
|
||||
return make_response(
|
||||
jsonify(
|
||||
{
|
||||
"success": True,
|
||||
"connectors": connectors,
|
||||
"allow_custom_mcp": allow_custom,
|
||||
"default_encryption_key": is_default_encryption_key(),
|
||||
"oauth_redirect_uri": settings.CONNECTOR_REDIRECT_BASE_URI,
|
||||
"mcp_redirect_uri": _mcp_redirect_uri(),
|
||||
}
|
||||
),
|
||||
200,
|
||||
)
|
||||
|
||||
@admin_required
|
||||
def put(self):
|
||||
"""Update policies: {policies: {key: {enabled?, credential_mode?}}, allow_custom_mcp?}."""
|
||||
body = request.get_json(silent=True) or {}
|
||||
updates = body.get("policies") or {}
|
||||
if not isinstance(updates, dict):
|
||||
return make_response(jsonify({"success": False, "message": "policies must be an object"}), 400)
|
||||
for key, change in updates.items():
|
||||
if catalog.get_definition(key) is None or not isinstance(change, dict):
|
||||
return make_response(jsonify({"success": False, "message": f"Unknown connector: {key}"}), 400)
|
||||
mode = change.get("credential_mode")
|
||||
if mode is not None and mode not in CREDENTIAL_POLICIES:
|
||||
return make_response(jsonify({"success": False, "message": "Unknown credential mode"}), 400)
|
||||
if "enabled" in change and not isinstance(change["enabled"], bool):
|
||||
return make_response(jsonify({"success": False, "message": "enabled must be true or false"}), 400)
|
||||
allow_custom = body.get("allow_custom_mcp")
|
||||
if allow_custom is not None and not isinstance(allow_custom, bool):
|
||||
return make_response(jsonify({"success": False, "message": "allow_custom_mcp must be a boolean"}), 400)
|
||||
actor = _actor()
|
||||
with db_session() as conn:
|
||||
repo = ConnectorPoliciesRepository(conn)
|
||||
for key, change in updates.items():
|
||||
repo.upsert(
|
||||
key,
|
||||
enabled=change.get("enabled"),
|
||||
credential_mode=change.get("credential_mode"),
|
||||
updated_by=actor,
|
||||
)
|
||||
if allow_custom is not None:
|
||||
AppMetadataRepository(conn).set(ALLOW_CUSTOM_MCP_KEY, "true" if allow_custom else "false")
|
||||
logger.info("connector_policies_updated", extra={"admin": actor, "connectors": sorted(updates)})
|
||||
return self.get()
|
||||
@@ -97,6 +97,8 @@ class ConnectionsList(Resource):
|
||||
)
|
||||
except service.EncryptionKeyNotConfigured as err:
|
||||
return _error(str(err), 400, code="encryption_key_default")
|
||||
except service.ConnectorDisabled as err:
|
||||
return _error(str(err), 403, code="disabled")
|
||||
except ValueError as err:
|
||||
return _error(str(err), 400)
|
||||
except Exception as err:
|
||||
@@ -448,3 +450,36 @@ class ConnectionRefreshTools(Resource):
|
||||
current_app.logger.error(f"Error refreshing MCP tools: {err}", exc_info=True)
|
||||
return _error("Failed to refresh tools", 502)
|
||||
return make_response(jsonify({"success": True, **diff}), 200)
|
||||
|
||||
|
||||
@connections_ns.route("/connections/tools/<string:tool_id>/credential-mode")
|
||||
class ToolCredentialMode(Resource):
|
||||
@api.doc(
|
||||
description=(
|
||||
"Whose account a shared connection-backed tool uses: {mode: owner | member}. "
|
||||
"Owner only; refused when an admin forces a mode for the connector."
|
||||
)
|
||||
)
|
||||
def put(self, tool_id: str):
|
||||
from docsgpt.connectors.resolve import MODE_MEMBER, MODE_OWNER
|
||||
from docsgpt.storage.db.repositories.user_tools import UserToolsRepository
|
||||
|
||||
user_id = _user_id()
|
||||
if not user_id:
|
||||
return _unauthorized()
|
||||
mode = _json_body().get("mode")
|
||||
if mode not in (MODE_OWNER, MODE_MEMBER):
|
||||
return _error("mode must be owner or member", 400)
|
||||
with db_session() as conn:
|
||||
tools = UserToolsRepository(conn)
|
||||
tool = tools.get_any(tool_id, user_id)
|
||||
if tool is None or tool.get("user_id") != user_id or not tool.get("connection_id"):
|
||||
return _error("Tool not found", 404)
|
||||
connection = ConnectorSessionsRepository(conn).get(str(tool["connection_id"]))
|
||||
forced = service.forced_credential_mode(
|
||||
conn, catalog.connector_key_for_row(connection) if connection else None,
|
||||
)
|
||||
if forced and forced != mode:
|
||||
return _error("An admin sets this for every share", 409, code="forced", mode=forced)
|
||||
tools.update(str(tool["id"]), user_id, {"credential_mode": mode})
|
||||
return make_response(jsonify({"success": True, "mode": mode}), 200)
|
||||
@@ -222,6 +222,8 @@ class ConnectorAuth(Resource):
|
||||
)
|
||||
except service.ConnectionUnavailable:
|
||||
return make_response(jsonify({"success": False, "error": "Connection not found"}), 404)
|
||||
except service.ConnectorDisabled as err:
|
||||
return make_response(jsonify({"success": False, "error": str(err), "code": "disabled"}), 403)
|
||||
# The popup drops results for origins outside the allowlist, which the
|
||||
# user only sees as a cancelled sign-in; name the missing origin here.
|
||||
request_origin = _origin_of(request.headers.get("Origin"))
|
||||
|
||||
@@ -482,6 +482,8 @@ def _remote_credentials(user, source, config):
|
||||
try:
|
||||
with db_session() as conn:
|
||||
row, _ = service.create_api_key_connection(conn, user, definition, provided)
|
||||
except service.ConnectorDisabled as err:
|
||||
return None, None, make_response(jsonify({"success": False, "error": str(err)}), 403)
|
||||
except (service.EncryptionKeyNotConfigured, ValueError):
|
||||
return config, None, None
|
||||
return public, str(row["id"]), None
|
||||
|
||||
@@ -112,6 +112,31 @@ def _mcp_connection(user, config, auth_type, auth_credentials, display_name):
|
||||
return str(row["id"]) if row else None
|
||||
|
||||
|
||||
def _mcp_policy_error(config: dict):
|
||||
"""A 403 when an admin turned this MCP server's connector off, else None.
|
||||
|
||||
A preset's own switch applies to its server; any other server is a
|
||||
custom connector and needs "Allow custom MCP servers".
|
||||
"""
|
||||
from docsgpt.connectors import catalog, service
|
||||
|
||||
preset = catalog.preset_for_url(config.get("server_url"))
|
||||
key = preset.key if preset else "custom_mcp"
|
||||
try:
|
||||
with db_readonly() as conn:
|
||||
service.ensure_connector_allowed(conn, key)
|
||||
except service.ConnectorDisabled:
|
||||
return make_response(
|
||||
jsonify({"success": False, "error": "This MCP server is turned off by an admin", "code": "disabled"}),
|
||||
403,
|
||||
)
|
||||
except Exception:
|
||||
# The switch is an admin preference; when the policy table cannot be
|
||||
# read, saving the tool (which needs the database) fails on its own.
|
||||
current_app.logger.warning("Could not read connector policies", exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
@tools_mcp_ns.route("/mcp_server/test")
|
||||
class TestMCPServerConfig(Resource):
|
||||
@api.expect(
|
||||
@@ -147,6 +172,9 @@ class TestMCPServerConfig(Resource):
|
||||
)
|
||||
|
||||
_validate_mcp_server_url(config)
|
||||
policy_error = _mcp_policy_error(config)
|
||||
if policy_error is not None:
|
||||
return policy_error
|
||||
|
||||
auth_credentials = _extract_auth_credentials(config)
|
||||
test_config = config.copy()
|
||||
@@ -243,6 +271,9 @@ class MCPServerSave(Resource):
|
||||
)
|
||||
|
||||
_validate_mcp_server_url(config)
|
||||
policy_error = _mcp_policy_error(config)
|
||||
if policy_error is not None:
|
||||
return policy_error
|
||||
|
||||
auth_credentials = _extract_auth_credentials(config)
|
||||
auth_type = config.get("auth_type", "none")
|
||||
|
||||
@@ -470,6 +470,8 @@ def _create_connected_tool(user, data, definition, tool_instance):
|
||||
)
|
||||
except connection_service.EncryptionKeyNotConfigured:
|
||||
return None
|
||||
except connection_service.ConnectorDisabled 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)
|
||||
return make_response(jsonify({"id": str(created["id"]), "connection_id": str(connection["id"])}), 200)
|
||||
|
||||
Reference in new issue
Block a user