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:
arc53-machine committed 2026-09-28 17:30:53 +01:00
1 parent 6e2ee43944
commit b692eaf7ad
20 files changed
+844 -4

No files matched your search

+1
View File
@@ -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"]
+130
View File
@@ -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()
+35
View File
@@ -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)
+2
View File
@@ -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"))
+2
View File
@@ -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
+31
View File
@@ -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")
+2
View File
@@ -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)