mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-15 00:16:46 +00:00
Let a GitHub connection opt into write tools
A GitHub connection's MCP tool can now point at GitHub's full endpoint (/mcp/) instead of the read-only one when its owner opts in, at setup (allow_writes) or later (PUT /api/connections/<id>/writes), which re-reads the actions and keeps the choices for those on both endpoints. Actions from the write endpoint are writes unless GitHub marks them read-only, so they default to asking first. Admins can forbid it per connector (allow_writes in Admin > Connectors, kept in app_metadata). Then the option is refused, a refresh goes back to read-only, and at run time the tool only ever calls the read-only endpoint and write calls are denied with a reason.
This commit is contained in:
1 parent
dba2de590b
commit
12ad4f5e85
9 files changed
+706
-38
No files matched your search
@@ -23,6 +23,7 @@ from docsgpt.storage.db.repositories.connector_policies import (
|
||||
ALLOW_CUSTOM_MCP_KEY,
|
||||
CREDENTIAL_POLICIES,
|
||||
ConnectorPoliciesRepository,
|
||||
allow_writes_key,
|
||||
)
|
||||
from docsgpt.storage.db.session import db_readonly, db_session
|
||||
|
||||
@@ -57,6 +58,7 @@ class AdminConnectorsResource(Resource):
|
||||
"""Every connector with its policy, setup state and connection count."""
|
||||
with db_readonly() as conn:
|
||||
policies = ConnectorPoliciesRepository(conn).all()
|
||||
loaded = service.load_policies(conn)
|
||||
allow_custom = service.custom_mcp_allowed(conn)
|
||||
counts = _connection_counts(conn)
|
||||
connectors = []
|
||||
@@ -88,6 +90,11 @@ class AdminConnectorsResource(Resource):
|
||||
"connection_count": counts.get(definition.key, 0),
|
||||
"docs_url": definition.docs_url,
|
||||
"mcp_url": definition.mcp_url,
|
||||
# Whether members may let agents make changes (GitHub);
|
||||
# None where the connector offers no such choice.
|
||||
"allow_writes": (
|
||||
service.writes_allowed(loaded, definition.key) if definition.mcp_write_url else None
|
||||
),
|
||||
}
|
||||
)
|
||||
return make_response(
|
||||
@@ -106,7 +113,7 @@ class AdminConnectorsResource(Resource):
|
||||
|
||||
@admin_required
|
||||
def put(self):
|
||||
"""Update policies: {policies: {key: {enabled?, credential_mode?}}, allow_custom_mcp?}."""
|
||||
"""Update policies: {policies: {key: {enabled?, credential_mode?, allow_writes?}}, allow_custom_mcp?}."""
|
||||
body = request.get_json(silent=True) or {}
|
||||
updates = body.get("policies") or {}
|
||||
if not isinstance(updates, dict):
|
||||
@@ -119,6 +126,15 @@ class AdminConnectorsResource(Resource):
|
||||
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)
|
||||
if "allow_writes" in change:
|
||||
if not catalog.get_definition(key).mcp_write_url:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": f"{key} has no write access to allow"}), 400,
|
||||
)
|
||||
if not isinstance(change["allow_writes"], bool):
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "allow_writes 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)
|
||||
@@ -126,6 +142,11 @@ class AdminConnectorsResource(Resource):
|
||||
with db_session() as conn:
|
||||
repo = ConnectorPoliciesRepository(conn)
|
||||
for key, change in updates.items():
|
||||
if "allow_writes" in change:
|
||||
value = "true" if change["allow_writes"] else "false"
|
||||
AppMetadataRepository(conn).set(allow_writes_key(key), value)
|
||||
if set(change) == {"allow_writes"}:
|
||||
continue
|
||||
repo.upsert(
|
||||
key,
|
||||
enabled=change.get("enabled"),
|
||||
|
||||
@@ -229,8 +229,9 @@ class ConnectionDisconnect(Resource):
|
||||
class ConnectionSetup(Resource):
|
||||
@api.doc(
|
||||
description=(
|
||||
"Apply the connect wizard's choices: {create_tools, tool_permissions?, "
|
||||
"sync?: {items, frequency, name?}}. Honours an Idempotency-Key header for the sync."
|
||||
"Apply the connect wizard's choices: {create_tools, allow_writes?, tool_permissions?, "
|
||||
"sync?: {items, frequency, name?}}. allow_writes points GitHub's tool at its write endpoint. "
|
||||
"Honours an Idempotency-Key header for the sync."
|
||||
)
|
||||
)
|
||||
def post(self, connection_id: str):
|
||||
@@ -239,22 +240,31 @@ class ConnectionSetup(Resource):
|
||||
return _unauthorized()
|
||||
body = _json_body()
|
||||
create_tools = body.get("create_tools", True)
|
||||
allow_writes = body.get("allow_writes", False)
|
||||
if not isinstance(allow_writes, bool):
|
||||
return _error("allow_writes must be true or false", 400)
|
||||
try:
|
||||
mcp_actions = None
|
||||
with db_readonly() as conn:
|
||||
row = _owned(conn, connection_id, user_id)
|
||||
discover = bool(row) and create_tools and service.needs_mcp_discovery(conn, row)
|
||||
forbidden = bool(row) and allow_writes and not service.writes_allowed(
|
||||
service.load_policies(conn), catalog.connector_key_for_row(row),
|
||||
)
|
||||
if row is None:
|
||||
return _not_found()
|
||||
if service.normalize_status(row) != service.STATUS_CONNECTED:
|
||||
return _error("Reconnect before setting up", 409, code="reconnect")
|
||||
if forbidden:
|
||||
return _error("Changes through this connector are turned off by an admin", 403,
|
||||
code="writes_forbidden")
|
||||
if discover:
|
||||
# GitHub's tool is its MCP server: read its actions before
|
||||
# the write transaction, not while holding it open.
|
||||
from docsgpt.connectors.mcp import discover_builtin_actions
|
||||
|
||||
try:
|
||||
mcp_actions = discover_builtin_actions(user_id, row)
|
||||
mcp_actions = discover_builtin_actions(user_id, row, writes=allow_writes)
|
||||
except service.ConnectionUnavailable:
|
||||
return _error("Reconnect before setting up", 409, code="reconnect")
|
||||
except Exception as err:
|
||||
@@ -269,7 +279,7 @@ class ConnectionSetup(Resource):
|
||||
if create_tools:
|
||||
tools = service.ensure_connection_tools(
|
||||
conn, user_id, row, permissions=body.get("tool_permissions") or None,
|
||||
mcp_actions=mcp_actions,
|
||||
mcp_actions=mcp_actions, mcp_writes=allow_writes,
|
||||
)
|
||||
account_parameters = service.connection_parameters(row)
|
||||
tool_payload = [service.serialize_tool(tool, account_parameters) for tool in tools]
|
||||
@@ -587,6 +597,43 @@ class ConnectionRefreshTools(Resource):
|
||||
return make_response(jsonify({"success": True, **diff}), 200)
|
||||
|
||||
|
||||
@connections_ns.route("/connections/<string:connection_id>/writes")
|
||||
class ConnectionWrites(Resource):
|
||||
@api.doc(
|
||||
description=(
|
||||
"GitHub: let agents make changes through this connection, or only read: {allow}. "
|
||||
"Re-reads the tool's actions from the matching endpoint. Owner only."
|
||||
)
|
||||
)
|
||||
def put(self, connection_id: str):
|
||||
from docsgpt.connectors.mcp import NoMcpTool, set_builtin_writes
|
||||
|
||||
user_id = _user_id()
|
||||
if not user_id:
|
||||
return _unauthorized()
|
||||
allow = _json_body().get("allow")
|
||||
if not isinstance(allow, bool):
|
||||
return _error("allow must be true or false", 400)
|
||||
with db_readonly() as conn:
|
||||
row = _owned(conn, connection_id, user_id)
|
||||
if row is None:
|
||||
return _not_found()
|
||||
try:
|
||||
result = set_builtin_writes(user_id, row, allow)
|
||||
except service.WritesForbidden as err:
|
||||
return _error(str(err), 403, code="writes_forbidden")
|
||||
except NoMcpTool as err:
|
||||
return _error(str(err), 409, code="no_tools")
|
||||
except ValueError as err:
|
||||
return _error(str(err), 400)
|
||||
except service.ConnectionUnavailable:
|
||||
return _error("Reconnect to continue", 409, code="reconnect")
|
||||
except Exception as err:
|
||||
current_app.logger.error(f"Error switching write access: {err}", exc_info=True)
|
||||
return _error("The service's tools could not be reached. Try again.", 502, code="tools_unavailable")
|
||||
return make_response(jsonify({"success": True, **result}), 200)
|
||||
|
||||
|
||||
@connections_ns.route("/connections/tools/<string:tool_id>/credential-mode")
|
||||
class ToolCredentialMode(Resource):
|
||||
@api.doc(
|
||||
|
||||
Reference in new issue
Block a user