feat(F.7+F.17): v3.11.0 — Coolify plugin (67 tools) + tool access overhaul
Some checks failed
Release / Test before release (push) Has been cancelled
Release / Publish to PyPI (push) Has been cancelled
Release / Publish to Docker Hub (push) Has been cancelled
Release / Create GitHub Release (push) Has been cancelled

Catch-up sync spanning v3.7.0 → v3.11.0 of the internal repo.

Platform
- Total tools: 565 → 633 (+68) across 10 plugins (Coolify added)
- Tests: 481 → 828 passing

New plugin: Coolify (67 tools, Track F.17)
- Applications (17): CRUD, lifecycle, logs, env vars
- Deployments (5): list/get/cancel/deploy, app history
- Servers (8): CRUD, resources, domains, validation
- Projects (8), Databases (16, 6 DB types + backups), Services (13)

Tool access system (Track F.7 → F.7d)
- Scope → category mapping with per-tool `category` + `sensitivity`
- Schema v7: `site_tool_toggles(site_id)` + `sites.tool_scope` column
- Schema v8: per-site API keys (`api_keys.site_id`)
- Plugin-specific access-level presets (WP / WC / Gitea / OpenPanel /
  Coolify 5-tier)
- Credential-requirement notice tailored per plugin and tier
- Admin Tools count card on service page
- Dropped redundant `write` tier on WP / WP Advanced / WooCommerce
  (admin-scope tool count = 0 → identical to admin tier)

Dashboard
- Unified site manage page (Connection / Tool Access / Connect)
- /dashboard/keys unified (was /api-keys and /connect)
- CSRF interceptor via meta-tag; removed conflicting cookie reader
- Tailwind: pre-built CSS (scripts/build-css.sh) replaces CDN

Docs
- README / DOCKER_README / CLAUDE updated to 633 tools / 10 plugins
- CHANGELOG entries for v3.7.0 → v3.11.0
- FastMCP compatibility note updated to 3.x (post-v3.5 upgrade)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-14 21:05:07 +02:00
parent d8a0805412
commit 788439e377
31 changed files with 1911 additions and 254 deletions

View File

@@ -112,6 +112,8 @@ def mock_key_mgr():
def mock_db():
"""Patch get_database to return a mock."""
db = AsyncMock()
db.get_site_tool_scope = AsyncMock(return_value="admin")
db.get_site_tool_toggles = AsyncMock(return_value={})
db.get_site_by_alias = AsyncMock(
return_value={
"id": "site-uuid-001",
@@ -148,6 +150,10 @@ def mock_tool_registry():
tool_def = MagicMock()
tool_def.name = "wordpress_list_posts"
tool_def.description = "List WordPress posts"
tool_def.plugin_type = "wordpress"
tool_def.required_scope = "read"
tool_def.category = "read"
tool_def.sensitivity = "normal"
tool_def.input_schema = {
"type": "object",
"properties": {
@@ -221,6 +227,62 @@ class TestAuthentication:
body = json.loads(response.body)
assert "does not match" in body["error"]["message"]
@pytest.mark.unit
async def test_site_scoped_key_wrong_site(self, mock_key_mgr, mock_db):
"""Site-scoped key (site_id=A) used for site B should return 403."""
# Key is scoped to site-A
mock_key_mgr.validate_key.return_value = {
"key_id": "key-uuid-001",
"user_id": "user-uuid-001",
"scopes": "read write",
"site_id": "site-uuid-A",
}
# The site looked up by site_id (A) has alias "blog-a", but the request
# is for alias "myblog" (which is site-B in get_site_by_alias).
mock_db.get_site = AsyncMock(
return_value={
"id": "site-uuid-A",
"alias": "blog-a",
"user_id": "user-uuid-001",
"plugin_type": "wordpress",
"url": "https://blog-a.example.com",
"credentials": b"x",
"status": "active",
}
)
request = _make_request(alias="myblog")
response = await user_mcp_handler(request)
assert response.status_code == 403
body = json.loads(response.body)
assert "scoped to a different site" in body["error"]["message"]
@pytest.mark.unit
async def test_site_scoped_key_matching_site(self, mock_key_mgr, mock_db, mock_tool_registry):
"""Site-scoped key used for the matching alias should pass auth."""
mock_key_mgr.validate_key.return_value = {
"key_id": "key-uuid-001",
"user_id": "user-uuid-001",
"scopes": "read write",
"site_id": "site-uuid-001",
}
mock_db.get_site = AsyncMock(
return_value={
"id": "site-uuid-001",
"alias": "myblog",
"user_id": "user-uuid-001",
"plugin_type": "wordpress",
"url": "https://myblog.example.com",
"credentials": b"x",
"status": "active",
}
)
request = _make_request(alias="myblog", method_name="tools/list")
response = await user_mcp_handler(request)
assert response.status_code == 200
body = json.loads(response.body)
assert "result" in body
assert "tools" in body["result"]
# ── Site Lookup Tests ────────────────────────────────────────