mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-16 08:14:32 +00:00
Add the connector catalog and read-only connections API
connector_sessions already holds one row per signed-in account or MCP server. Migration 0038 names each row (connector_key, auth_kind, display_name, account_label) and lets sources and tools point at the row they use through connection_id, backfilling the links it can prove. docsgpt/connectors/catalog.py declares every service DocsGPT connects to, with the server settings it needs and what a connection provides. New endpoints: GET /api/connectors/catalog, GET /api/connections, GET /api/connections/<id> and POST /api/connections/<id>/disconnect. /api/available_tools now groups tools into built_in, service and custom, and source listings carry connectionId.
This commit is contained in:
1 parent
335f2af70c
commit
5321a94388
17 files changed
+1714
-2
No files matched your search
@@ -0,0 +1,106 @@
|
||||
"""Connectors catalog and connections API.
|
||||
|
||||
``/api/connectors/catalog`` lists every service DocsGPT can connect to, with
|
||||
whether the server is set up for it and the caller's connection summary.
|
||||
``/api/connections`` lists and manages the caller's connections. Responses
|
||||
never include tokens or secrets.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from flask import current_app, jsonify, make_response, request
|
||||
from flask_restx import Namespace, Resource
|
||||
|
||||
from docsgpt.api import api
|
||||
from docsgpt.api.user.authz import ROLE_ADMIN, has_role
|
||||
from docsgpt.connectors import service
|
||||
from docsgpt.storage.db.repositories.connector_sessions import ConnectorSessionsRepository
|
||||
from docsgpt.storage.db.session import db_readonly, db_session
|
||||
|
||||
connections_ns = Namespace("connections", description="Connectors and connections", path="/api")
|
||||
api.add_namespace(connections_ns)
|
||||
|
||||
|
||||
def _user_id() -> str | None:
|
||||
token = getattr(request, "decoded_token", None)
|
||||
return token.get("sub") if isinstance(token, dict) else None
|
||||
|
||||
|
||||
def _unauthorized():
|
||||
return make_response(jsonify({"success": False, "error": "Unauthorized"}), 401)
|
||||
|
||||
|
||||
def _not_found():
|
||||
return make_response(jsonify({"success": False, "error": "Connection not found"}), 404)
|
||||
|
||||
|
||||
@connections_ns.route("/connectors/catalog")
|
||||
class ConnectorCatalog(Resource):
|
||||
@api.doc(description="Every connector with its availability and the caller's connection summary")
|
||||
def get(self):
|
||||
user_id = _user_id()
|
||||
if not user_id:
|
||||
return _unauthorized()
|
||||
try:
|
||||
with db_readonly() as conn:
|
||||
entries = service.catalog_for_user(
|
||||
conn, user_id, is_admin=has_role(request.decoded_token, ROLE_ADMIN),
|
||||
)
|
||||
except Exception as err:
|
||||
current_app.logger.error(f"Error building connector catalog: {err}", exc_info=True)
|
||||
return make_response(jsonify({"success": False, "error": "Failed to load connectors"}), 500)
|
||||
return make_response(jsonify({"success": True, "connectors": entries}), 200)
|
||||
|
||||
|
||||
@connections_ns.route("/connections")
|
||||
class ConnectionsList(Resource):
|
||||
@api.doc(description="The caller's connections with status and linked resource counts")
|
||||
def get(self):
|
||||
user_id = _user_id()
|
||||
if not user_id:
|
||||
return _unauthorized()
|
||||
try:
|
||||
with db_readonly() as conn:
|
||||
connections = service.list_connections(conn, user_id)
|
||||
except Exception as err:
|
||||
current_app.logger.error(f"Error listing connections: {err}", exc_info=True)
|
||||
return make_response(jsonify({"success": False, "error": "Failed to load connections"}), 500)
|
||||
return make_response(jsonify({"success": True, "connections": connections}), 200)
|
||||
|
||||
|
||||
@connections_ns.route("/connections/<string:connection_id>")
|
||||
class ConnectionDetail(Resource):
|
||||
@api.doc(description="One connection with the sources it syncs and the tools it provides")
|
||||
def get(self, connection_id: str):
|
||||
user_id = _user_id()
|
||||
if not user_id:
|
||||
return _unauthorized()
|
||||
try:
|
||||
with db_readonly() as conn:
|
||||
row = ConnectorSessionsRepository(conn).get_for_user(connection_id, user_id)
|
||||
if row is None:
|
||||
return _not_found()
|
||||
detail = service.connection_detail(conn, row)
|
||||
except Exception as err:
|
||||
current_app.logger.error(f"Error loading connection: {err}", exc_info=True)
|
||||
return make_response(jsonify({"success": False, "error": "Failed to load connection"}), 500)
|
||||
return make_response(jsonify({"success": True, "connection": detail}), 200)
|
||||
|
||||
|
||||
@connections_ns.route("/connections/<string:connection_id>/disconnect")
|
||||
class ConnectionDisconnect(Resource):
|
||||
@api.doc(description="Delete a connection's stored credentials; its sources and tools stay")
|
||||
def post(self, connection_id: str):
|
||||
user_id = _user_id()
|
||||
if not user_id:
|
||||
return _unauthorized()
|
||||
try:
|
||||
with db_session() as conn:
|
||||
row = ConnectorSessionsRepository(conn).get_for_user(connection_id, user_id)
|
||||
if row is None:
|
||||
return _not_found()
|
||||
connection = service.disconnect(conn, row)
|
||||
except Exception as err:
|
||||
current_app.logger.error(f"Error disconnecting connection: {err}", exc_info=True)
|
||||
return make_response(jsonify({"success": False, "error": "Failed to disconnect"}), 500)
|
||||
return make_response(jsonify({"success": True, "connection": connection}), 200)
|
||||
@@ -357,6 +357,7 @@ DENIED_PREFIXES = (
|
||||
"/api/admin/",
|
||||
"/api/auth/oidc/",
|
||||
"/api/connectors/",
|
||||
"/api/connections",
|
||||
"/api/devices",
|
||||
"/scim/",
|
||||
"/static/",
|
||||
|
||||
@@ -67,6 +67,12 @@ def _get_provider_from_remote_data(remote_data):
|
||||
return None
|
||||
|
||||
|
||||
def _connection_id(row: dict) -> str | None:
|
||||
"""The connection a source syncs from, as a string id, or None."""
|
||||
value = row.get("connection_id")
|
||||
return str(value) if value else None
|
||||
|
||||
|
||||
@sources_ns.route("/sources")
|
||||
class CombinedJson(Resource):
|
||||
@api.doc(description="Provide JSON file with combined available indexes")
|
||||
@@ -117,6 +123,7 @@ class CombinedJson(Resource):
|
||||
"config": SourceConfig.parse(index.get("config")).model_dump(),
|
||||
"ownership": ownership,
|
||||
"team_access": team_access,
|
||||
"connectionId": _connection_id(index),
|
||||
}
|
||||
|
||||
for index in indexes:
|
||||
@@ -207,6 +214,7 @@ class PaginatedSources(Resource):
|
||||
"team_access": (
|
||||
None if owned else team_shared.get(str(doc["id"]))
|
||||
),
|
||||
"connectionId": _connection_id(doc),
|
||||
}
|
||||
)
|
||||
response = {
|
||||
|
||||
@@ -19,6 +19,7 @@ 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.connectors.catalog import definition_for_tool
|
||||
from docsgpt.core.settings import settings
|
||||
from docsgpt.core.url_validation import SSRFError, validate_url
|
||||
from docsgpt.security.encryption import decrypt_credentials, encrypt_credentials
|
||||
@@ -186,6 +187,10 @@ def transform_actions(actions_metadata):
|
||||
|
||||
tools_ns = Namespace("tools", description="Tool management operations", path="/api")
|
||||
|
||||
# Tools the Connectors page adds through "Add custom connector" rather than
|
||||
# the Add Tool modal.
|
||||
_CUSTOM_CONNECTOR_TOOLS = {"mcp_tool": "custom_mcp", "api_tool": "custom_openapi"}
|
||||
|
||||
|
||||
@tools_ns.route("/available_tools")
|
||||
class AvailableTools(Resource):
|
||||
@@ -202,6 +207,13 @@ class AvailableTools(Resource):
|
||||
description = lines[1].strip() if len(lines) > 1 else ""
|
||||
config_req = tool_instance.get_config_requirements()
|
||||
actions = tool_instance.get_actions_metadata()
|
||||
definition = definition_for_tool(tool_name)
|
||||
if definition is not None:
|
||||
group, connector_key = "service", definition.key
|
||||
elif tool_name in _CUSTOM_CONNECTOR_TOOLS:
|
||||
group, connector_key = "custom", _CUSTOM_CONNECTOR_TOOLS[tool_name]
|
||||
else:
|
||||
group, connector_key = "built_in", None
|
||||
tools_metadata.append(
|
||||
{
|
||||
"name": tool_name,
|
||||
@@ -209,6 +221,8 @@ class AvailableTools(Resource):
|
||||
"description": description,
|
||||
"configRequirements": config_req,
|
||||
"actions": actions,
|
||||
"group": group,
|
||||
"connector_key": connector_key,
|
||||
}
|
||||
)
|
||||
except Exception as err:
|
||||
|
||||
Reference in new issue
Block a user