mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-04 20:13:04 +00:00
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.
364 lines
17 KiB
Python
364 lines
17 KiB
Python
"""GitHub write access: opted into per connection, forbidden by an admin, enforced at run time."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from contextlib import contextmanager
|
|
from types import SimpleNamespace
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
from flask import Flask
|
|
from sqlalchemy import text
|
|
|
|
import docsgpt.api.user # noqa: F401 (loads mcp_tool without the circular import)
|
|
import docsgpt.agents.tools.mcp_tool # noqa: F401,E402 (patched below)
|
|
from docsgpt.connectors import catalog, mcp, service
|
|
from docsgpt.security.encryption import encrypt_json
|
|
|
|
READONLY_MCP = "https://api.githubcopilot.com/mcp/readonly"
|
|
WRITE_MCP = "https://api.githubcopilot.com/mcp/"
|
|
|
|
|
|
@pytest.fixture
|
|
def app():
|
|
return Flask(__name__)
|
|
|
|
|
|
@contextmanager
|
|
def _db(conn):
|
|
@contextmanager
|
|
def _yield():
|
|
yield conn
|
|
|
|
with patch.multiple("docsgpt.api.connector.connections", db_session=_yield, db_readonly=_yield), \
|
|
patch.multiple("docsgpt.api.admin.connectors", db_session=_yield, db_readonly=_yield), \
|
|
patch.multiple("docsgpt.connectors.service", db_session=_yield, db_readonly=_yield), \
|
|
patch.multiple("docsgpt.connectors.mcp", db_session=_yield, db_readonly=_yield), \
|
|
patch.multiple("docsgpt.connectors.resolve", db_readonly=_yield):
|
|
yield
|
|
|
|
|
|
def _call(app, resource, method, path, user="alice", body=None, args=(), roles=None):
|
|
with app.test_request_context(path, method=method.upper(), json=body):
|
|
from flask import request
|
|
|
|
request.decoded_token = {"sub": user, "roles": roles or ["user"]} if user else None
|
|
return getattr(resource(), method)(*args)
|
|
|
|
|
|
def _connection(conn, user="alice") -> str:
|
|
return str(conn.execute(
|
|
text(
|
|
"INSERT INTO connector_sessions (user_id, provider, connector_key, auth_kind, status, account_label, "
|
|
"encrypted_credentials) VALUES (:u, 'github', 'github', 'api_key', 'connected', 'octocat', :e) "
|
|
"RETURNING id"
|
|
),
|
|
{"u": user, "e": encrypt_json({"credentials": {"access_token": "github_pat_alice"}}, user)},
|
|
).scalar())
|
|
|
|
|
|
def _tool(conn, cid, server_url, actions) -> str:
|
|
return str(conn.execute(
|
|
text(
|
|
"INSERT INTO user_tools (user_id, name, config, actions, connection_id) VALUES ('alice', 'mcp_tool', "
|
|
"CAST(:c AS jsonb), CAST(:a AS jsonb), CAST(:cid AS uuid)) RETURNING id"
|
|
),
|
|
{"c": json.dumps({"server_url": server_url, "auth_type": "bearer", "transport_type": "http", "timeout": 30}),
|
|
"a": json.dumps(actions), "cid": cid},
|
|
).scalar())
|
|
|
|
|
|
def _row(conn, cid) -> dict:
|
|
return dict(conn.execute(
|
|
text("SELECT * FROM connector_sessions WHERE id = CAST(:c AS uuid)"), {"c": cid},
|
|
).one()._mapping)
|
|
|
|
|
|
def _stored(conn, cid) -> tuple[dict, dict]:
|
|
config, actions = conn.execute(
|
|
text("SELECT config, actions FROM user_tools WHERE connection_id = CAST(:c AS uuid)"), {"c": cid},
|
|
).one()
|
|
return config, {a["name"]: a for a in actions}
|
|
|
|
|
|
def _forbid(conn):
|
|
from docsgpt.storage.db.repositories.app_metadata import AppMetadataRepository
|
|
from docsgpt.storage.db.repositories.connector_policies import allow_writes_key
|
|
|
|
AppMetadataRepository(conn).set(allow_writes_key("github"), "false")
|
|
|
|
|
|
def _action(name, read_only=None):
|
|
action = {"name": name, "description": name,
|
|
"parameters": {"type": "object", "properties": {"owner": {"type": "string"}}}}
|
|
if read_only is not None:
|
|
action["annotations"] = {"readOnlyHint": read_only}
|
|
return action
|
|
|
|
|
|
# What GitHub's servers list: the read-only endpoint only reads; the full one
|
|
# adds writes, annotated ``readOnlyHint: false`` (or not annotated at all).
|
|
READ_ACTIONS = [_action("get_issue", True), _action("search_code", True)]
|
|
WRITE_ACTIONS = READ_ACTIONS + [
|
|
_action("create_issue", False),
|
|
_action("add_issue_comment", False),
|
|
_action("mark_all_notifications_read"),
|
|
]
|
|
|
|
|
|
def _fake_discovery(calls):
|
|
"""Stands in for GitHub's MCP server: lists the actions of the endpoint asked for."""
|
|
|
|
def discover(user_id, connection, tool):
|
|
url = tool["config"]["server_url"]
|
|
calls.append(url)
|
|
return [dict(a) for a in (WRITE_ACTIONS if url == WRITE_MCP else READ_ACTIONS)]
|
|
|
|
return discover
|
|
|
|
|
|
class TestCatalog:
|
|
def test_github_offers_writes_on_its_full_endpoint(self):
|
|
definition = catalog.get_definition("github")
|
|
assert definition.mcp_url == READONLY_MCP
|
|
assert definition.mcp_write_url == WRITE_MCP
|
|
assert definition.to_dict()["writes_opt_in"] is True
|
|
assert catalog.get_definition("telegram").to_dict()["writes_opt_in"] is False
|
|
|
|
def test_members_see_whether_writes_are_allowed(self, pg_conn):
|
|
entries = {e["key"]: e for e in service.catalog_for_user(pg_conn, "alice", is_admin=False)}
|
|
assert entries["github"]["writes_allowed"] is True
|
|
assert entries["telegram"]["writes_allowed"] is False
|
|
_forbid(pg_conn)
|
|
entries = {e["key"]: e for e in service.catalog_for_user(pg_conn, "alice", is_admin=False)}
|
|
assert entries["github"]["writes_allowed"] is False
|
|
|
|
def test_builtin_config_picks_the_endpoint(self):
|
|
definition = catalog.get_definition("github")
|
|
assert service.builtin_mcp_config(definition)["server_url"] == READONLY_MCP
|
|
assert service.builtin_mcp_config(definition, writes=True)["server_url"] == WRITE_MCP
|
|
|
|
|
|
class TestClassification:
|
|
def test_full_endpoint_actions_are_writes_unless_marked_read_only(self):
|
|
"""A GitHub action is a read only when GitHub says so: a name like
|
|
``mark_all_notifications_read`` must not pass for one."""
|
|
connection = {"id": "c1", "auth_kind": "api_key", "status": "connected", "connector_key": "github"}
|
|
fake = MagicMock()
|
|
fake.get_actions_metadata.return_value = [dict(a) for a in WRITE_ACTIONS]
|
|
with patch.object(service, "access_credentials", return_value={"access_token": "t"}), \
|
|
patch("docsgpt.agents.tools.mcp_tool.MCPTool", return_value=fake):
|
|
actions = mcp.discover_builtin_actions("alice", connection, writes=True)
|
|
access = {a["name"]: a["access"] for a in actions}
|
|
assert access == {
|
|
"get_issue": "read", "search_code": "read",
|
|
"create_issue": "write", "add_issue_comment": "write", "mark_all_notifications_read": "write",
|
|
}
|
|
|
|
|
|
class TestSetup:
|
|
def test_opting_in_uses_the_full_endpoint_and_writes_ask_first(self, app, pg_conn):
|
|
from docsgpt.api.connector.connections import ConnectionDetail, ConnectionSetup
|
|
|
|
cid = _connection(pg_conn)
|
|
calls = []
|
|
with _db(pg_conn), patch.object(mcp, "_discover", side_effect=_fake_discovery(calls)):
|
|
resp = _call(app, ConnectionSetup, "post", f"/api/connections/{cid}/setup",
|
|
body={"create_tools": True, "allow_writes": True}, args=[cid])
|
|
detail = _call(app, ConnectionDetail, "get", f"/api/connections/{cid}", args=[cid]).get_json()
|
|
assert resp.status_code == 200
|
|
assert calls == [WRITE_MCP]
|
|
config, actions = _stored(pg_conn, cid)
|
|
assert config["server_url"] == WRITE_MCP
|
|
assert actions["create_issue"]["access"] == "write" and actions["create_issue"]["require_approval"] is True
|
|
assert actions["mark_all_notifications_read"]["access"] == "write"
|
|
assert actions["get_issue"]["access"] == "read" and not actions["get_issue"].get("require_approval")
|
|
assert detail["connection"]["writes"] is True
|
|
permissions = {a["name"]: a["permission"] for a in detail["connection"]["tools"][0]["actions"]}
|
|
assert permissions["create_issue"] == "ask" and permissions["get_issue"] == "always"
|
|
|
|
def test_read_only_is_the_default(self, app, pg_conn):
|
|
from docsgpt.api.connector.connections import ConnectionDetail, ConnectionSetup
|
|
|
|
cid = _connection(pg_conn)
|
|
calls = []
|
|
with _db(pg_conn), patch.object(mcp, "_discover", side_effect=_fake_discovery(calls)):
|
|
_call(app, ConnectionSetup, "post", f"/api/connections/{cid}/setup",
|
|
body={"create_tools": True}, args=[cid])
|
|
detail = _call(app, ConnectionDetail, "get", f"/api/connections/{cid}", args=[cid]).get_json()
|
|
assert calls == [READONLY_MCP]
|
|
assert _stored(pg_conn, cid)[0]["server_url"] == READONLY_MCP
|
|
assert detail["connection"]["writes"] is False
|
|
|
|
def test_forbidden_writes_are_refused(self, app, pg_conn):
|
|
from docsgpt.api.connector.connections import ConnectionSetup
|
|
|
|
cid = _connection(pg_conn)
|
|
_forbid(pg_conn)
|
|
with _db(pg_conn), patch.object(mcp, "_discover") as discover:
|
|
resp = _call(app, ConnectionSetup, "post", f"/api/connections/{cid}/setup",
|
|
body={"create_tools": True, "allow_writes": True}, args=[cid])
|
|
assert resp.status_code == 403
|
|
assert resp.get_json()["code"] == "writes_forbidden"
|
|
discover.assert_not_called()
|
|
assert pg_conn.execute(text("SELECT count(*) FROM user_tools")).scalar() == 0
|
|
|
|
|
|
class TestSwitchWrites:
|
|
def _read_only_tool(self, conn, cid):
|
|
actions = mcp.apply_default_permissions("mcp_tool", service._transform_actions(
|
|
[dict(a) for a in READ_ACTIONS]))
|
|
# The user turned one read off; that choice survives switching.
|
|
actions = [{**a, "active": a["name"] != "search_code"} for a in actions]
|
|
return _tool(conn, cid, READONLY_MCP, actions)
|
|
|
|
def test_turning_writes_on_and_off_keeps_choices(self, app, pg_conn):
|
|
from docsgpt.api.connector.connections import ConnectionWrites
|
|
|
|
cid = _connection(pg_conn)
|
|
self._read_only_tool(pg_conn, cid)
|
|
calls = []
|
|
with _db(pg_conn), patch.object(mcp, "_discover", side_effect=_fake_discovery(calls)):
|
|
on = _call(app, ConnectionWrites, "put", f"/api/connections/{cid}/writes",
|
|
body={"allow": True}, args=[cid])
|
|
config, actions = _stored(pg_conn, cid)
|
|
assert on.status_code == 200
|
|
assert on.get_json()["writes"] is True
|
|
assert sorted(on.get_json()["added"]) == ["add_issue_comment", "create_issue",
|
|
"mark_all_notifications_read"]
|
|
assert config["server_url"] == WRITE_MCP
|
|
assert actions["search_code"]["active"] is False
|
|
assert actions["create_issue"]["require_approval"] is True
|
|
off = _call(app, ConnectionWrites, "put", f"/api/connections/{cid}/writes",
|
|
body={"allow": False}, args=[cid])
|
|
assert calls == [WRITE_MCP, READONLY_MCP]
|
|
config, actions = _stored(pg_conn, cid)
|
|
assert off.get_json()["writes"] is False
|
|
assert config["server_url"] == READONLY_MCP
|
|
assert set(actions) == {"get_issue", "search_code"}
|
|
assert actions["search_code"]["active"] is False
|
|
|
|
def test_forbidden_writes_cannot_be_turned_on_but_can_be_turned_off(self, app, pg_conn):
|
|
from docsgpt.api.connector.connections import ConnectionWrites
|
|
|
|
cid = _connection(pg_conn)
|
|
_tool(pg_conn, cid, WRITE_MCP, [_action("create_issue", False)])
|
|
_forbid(pg_conn)
|
|
calls = []
|
|
with _db(pg_conn), patch.object(mcp, "_discover", side_effect=_fake_discovery(calls)):
|
|
on = _call(app, ConnectionWrites, "put", "/x", body={"allow": True}, args=[cid])
|
|
off = _call(app, ConnectionWrites, "put", "/x", body={"allow": False}, args=[cid])
|
|
assert on.status_code == 403 and on.get_json()["code"] == "writes_forbidden"
|
|
assert off.status_code == 200
|
|
assert _stored(pg_conn, cid)[0]["server_url"] == READONLY_MCP
|
|
|
|
def test_owner_only_github_only_and_needs_the_tool(self, app, pg_conn):
|
|
from docsgpt.api.connector.connections import ConnectionWrites
|
|
|
|
cid = _connection(pg_conn)
|
|
with _db(pg_conn):
|
|
bob = _call(app, ConnectionWrites, "put", "/x", user="bob", body={"allow": True}, args=[cid])
|
|
no_tool = _call(app, ConnectionWrites, "put", "/x", body={"allow": True}, args=[cid])
|
|
bad = _call(app, ConnectionWrites, "put", "/x", body={"allow": "yes"}, args=[cid])
|
|
assert bob.status_code == 404
|
|
assert no_tool.status_code == 409 and no_tool.get_json()["code"] == "no_tools"
|
|
assert bad.status_code == 400
|
|
telegram = str(pg_conn.execute(text(
|
|
"INSERT INTO connector_sessions (user_id, provider, connector_key, auth_kind, status, account_label) "
|
|
"VALUES ('alice', 'telegram', 'telegram', 'api_key', 'connected', 'bot') RETURNING id"
|
|
)).scalar())
|
|
with _db(pg_conn):
|
|
other = _call(app, ConnectionWrites, "put", "/x", body={"allow": True}, args=[telegram])
|
|
assert other.status_code == 400
|
|
|
|
def test_refresh_after_an_admin_forbids_writes_goes_read_only(self, pg_conn):
|
|
cid = _connection(pg_conn)
|
|
_tool(pg_conn, cid, WRITE_MCP, [_action("create_issue", False)])
|
|
_forbid(pg_conn)
|
|
calls = []
|
|
with _db(pg_conn), patch.object(mcp, "_discover", side_effect=_fake_discovery(calls)):
|
|
result = mcp.refresh_mcp_tools("alice", _row(pg_conn, cid))
|
|
assert calls == [READONLY_MCP]
|
|
assert result["removed"] == ["create_issue"]
|
|
assert _stored(pg_conn, cid)[0]["server_url"] == READONLY_MCP
|
|
|
|
|
|
class TestAdmin:
|
|
def test_admin_can_forbid_github_writes(self, app, pg_conn):
|
|
from docsgpt.api.admin.connectors import AdminConnectorsResource
|
|
|
|
with _db(pg_conn):
|
|
before = _call(app, AdminConnectorsResource, "get", "/x", roles=["admin"]).get_json()
|
|
resp = _call(app, AdminConnectorsResource, "put", "/x", roles=["admin"],
|
|
body={"policies": {"github": {"allow_writes": False}}})
|
|
rows = {c["key"]: c for c in before["connectors"]}
|
|
assert rows["github"]["allow_writes"] is True
|
|
assert rows["telegram"]["allow_writes"] is None
|
|
assert resp.status_code == 200
|
|
assert {c["key"]: c for c in resp.get_json()["connectors"]}["github"]["allow_writes"] is False
|
|
assert service.writes_allowed(service.load_policies(pg_conn), "github") is False
|
|
|
|
def test_rejects_writes_policy_where_it_means_nothing(self, app, pg_conn):
|
|
from docsgpt.api.admin.connectors import AdminConnectorsResource
|
|
|
|
with _db(pg_conn):
|
|
other = _call(app, AdminConnectorsResource, "put", "/x", roles=["admin"],
|
|
body={"policies": {"telegram": {"allow_writes": False}}})
|
|
not_bool = _call(app, AdminConnectorsResource, "put", "/x", roles=["admin"],
|
|
body={"policies": {"github": {"allow_writes": "no"}}})
|
|
assert other.status_code == 400
|
|
assert not_bool.status_code == 400
|
|
|
|
|
|
def _tool_data(cid, server_url=WRITE_MCP):
|
|
return {
|
|
"id": "tool-gh", "user_id": "alice", "name": "mcp_tool", "connection_id": cid,
|
|
"config": {"server_url": server_url, "auth_type": "bearer"}, "credential_mode": "owner",
|
|
"actions": [
|
|
{"name": "create_issue", "access": "write", "active": True, "require_approval": False},
|
|
{"name": "get_issue", "access": "read", "active": True},
|
|
],
|
|
}
|
|
|
|
|
|
class TestRuntime:
|
|
def _loaded_url(self, pg_conn, tool):
|
|
from docsgpt.agents.tool_executor import ToolExecutor
|
|
|
|
with _db(pg_conn), patch("docsgpt.agents.tool_executor.ToolManager") as manager:
|
|
ToolExecutor(user="alice")._get_or_load_tool(tool, "t1", "create_issue")
|
|
return manager.return_value.load_tool.call_args.kwargs["tool_config"]["server_url"]
|
|
|
|
def test_write_endpoint_is_used_while_allowed(self, pg_conn):
|
|
assert self._loaded_url(pg_conn, _tool_data(_connection(pg_conn))) == WRITE_MCP
|
|
|
|
def test_forbidden_writes_fall_back_to_the_read_only_endpoint(self, pg_conn):
|
|
cid = _connection(pg_conn)
|
|
_forbid(pg_conn)
|
|
assert self._loaded_url(pg_conn, _tool_data(cid)) == READONLY_MCP
|
|
|
|
def test_any_other_path_on_githubs_host_is_read_only(self, pg_conn):
|
|
cid = _connection(pg_conn)
|
|
assert self._loaded_url(pg_conn, _tool_data(cid, "https://api.githubcopilot.com/mcp/x/all")) == READONLY_MCP
|
|
|
|
def _pause(self, pg_conn, action_name):
|
|
from docsgpt.agents.tool_executor import ToolExecutor
|
|
|
|
cid = _connection(pg_conn)
|
|
_forbid(pg_conn)
|
|
with _db(pg_conn), patch("docsgpt.agents.tool_executor.ToolActionParser") as parser:
|
|
parser.return_value.parse_args.return_value = ("t1", action_name, {})
|
|
return ToolExecutor(user="alice").check_pause(
|
|
{"t1": _tool_data(cid)}, SimpleNamespace(id="c1", name=action_name, thought_signature=None),
|
|
"OpenAILLM",
|
|
)
|
|
|
|
def test_forbidden_write_is_denied_with_a_reason(self, pg_conn):
|
|
pause = self._pause(pg_conn, "create_issue")
|
|
assert pause["pause_type"] == "headless_denied"
|
|
assert "admin" in pause["deny_reason"]
|
|
|
|
def test_reads_still_run_when_writes_are_forbidden(self, pg_conn):
|
|
assert self._pause(pg_conn, "get_issue") is None
|