From 7c59295934d463ea09ad7b2d20df4aa991bb4462 Mon Sep 17 00:00:00 2001 From: TomMass Date: Wed, 18 Mar 2026 11:53:18 +0100 Subject: [PATCH 01/15] Add MCP activity tracking, fix progress bar CSS - New mcp_activity table (db/08_mcp_activity.sql) tracks 12 mutating MCP tools: create/delete project, create/update/delete/move/duplicate section, add/remove dependency, add/delete comment, bulk status - _record_activity() helper in server.py with fire-and-forget semantics - Token stats endpoint extended with activity feed (last 50 operations) - Fix duplicate display:none on chat progress bar in index.html - 5 new tests for activity recording and API integration - Migration mounted in both docker-compose files --- db/08_mcp_activity.sql | 11 +++++++ docker-compose.prod.yml | 1 + docker-compose.yml | 1 + mcp_server/server.py | 27 ++++++++++++++++ tests/conftest.py | 1 + tests/test_mcp_tools.py | 69 +++++++++++++++++++++++++++++++++++++++++ ui/app.py | 11 +++++++ ui/static/index.html | 2 +- 8 files changed, 122 insertions(+), 1 deletion(-) create mode 100644 db/08_mcp_activity.sql diff --git a/db/08_mcp_activity.sql b/db/08_mcp_activity.sql new file mode 100644 index 0000000..35fea6c --- /dev/null +++ b/db/08_mcp_activity.sql @@ -0,0 +1,11 @@ +-- MCP activity tracking (write operations only) +CREATE TABLE IF NOT EXISTS mcp_activity ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + project_id UUID REFERENCES projects(id) ON DELETE CASCADE, + tool_name TEXT NOT NULL, + detail JSONB DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_mcp_activity_project + ON mcp_activity(project_id, created_at DESC); diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index c647e7f..f244a4d 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -18,6 +18,7 @@ services: - ./db/04_replies_and_settings.sql:/docker-entrypoint-initdb.d/04_replies_and_settings.sql:ro - ./db/05_token_stats.sql:/docker-entrypoint-initdb.d/05_token_stats.sql:ro - ./db/06_chat.sql:/docker-entrypoint-initdb.d/06_chat.sql:ro + - ./db/08_mcp_activity.sql:/docker-entrypoint-initdb.d/08_mcp_activity.sql:ro healthcheck: test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-prdforge}"] interval: 3s diff --git a/docker-compose.yml b/docker-compose.yml index 5e59dd4..a57058c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -16,6 +16,7 @@ services: - ./db/04_replies_and_settings.sql:/docker-entrypoint-initdb.d/04_replies_and_settings.sql:ro - ./db/05_token_stats.sql:/docker-entrypoint-initdb.d/05_token_stats.sql:ro - ./db/06_chat.sql:/docker-entrypoint-initdb.d/06_chat.sql:ro + - ./db/08_mcp_activity.sql:/docker-entrypoint-initdb.d/08_mcp_activity.sql:ro healthcheck: test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-prdforge}"] interval: 3s diff --git a/mcp_server/server.py b/mcp_server/server.py index 4223213..2852e00 100644 --- a/mcp_server/server.py +++ b/mcp_server/server.py @@ -216,6 +216,7 @@ async def prd_create_project(name: str, slug: str, description: str = "") -> str name, slug, description, ) logger.info("Created project: %s", slug) + await _record_activity(pool, row["id"], "prd_create_project", {"slug": slug}) return ok({"created": row_to_dict(row)}) except asyncpg.UniqueViolationError: return err(f"project with slug '{slug}' already exists") @@ -235,6 +236,7 @@ async def prd_delete_project(project: str) -> str: if not pid: return err(f"project '{project}' not found") count = await pool.fetchval("SELECT COUNT(*) FROM sections WHERE project_id = $1", pid) + await _record_activity(pool, pid, "prd_delete_project", {"slug": project, "sections": count}) await pool.execute("DELETE FROM projects WHERE id = $1", pid) logger.info("Deleted project: %s (%d sections)", project, count) return ok({"deleted": project, "sections_removed": count}) @@ -413,6 +415,7 @@ async def prd_create_section( "Initial section creation", ) + await _record_activity(pool, pid, "prd_create_section", {"slug": slug, "title": title}) created = row_to_dict(row) created.pop("id", None) logger.info("Created section: %s/%s", project, slug) @@ -531,6 +534,7 @@ async def prd_update_section( """, valid_cids, row["id"]) resolved_ids = [str(r["id"]) for r in resolved_rows] + await _record_activity(pool, pid, "prd_update_section", {"slug": section, "fields": list(fields.keys())}) result = {"updated": row_to_dict(updated)} if revision_created is not None: result["revision_created"] = revision_created @@ -566,6 +570,7 @@ async def prd_delete_section(project: str, section: str) -> str: ) affected = [r["slug"] for r in depended_by] + await _record_activity(pool, pid, "prd_delete_section", {"slug": section}) await pool.execute("DELETE FROM sections WHERE id = $1", sec["id"]) result = {"deleted": section} if affected: @@ -629,6 +634,7 @@ async def prd_move_section( f"UPDATE sections SET {', '.join(sets)} WHERE id = ${len(vals)}", *vals ) + await _record_activity(pool, pid, "prd_move_section", {"slug": section}) new_order = sort_order if sort_order is not None else sec["sort_order"] return ok({"moved": {"slug": section, "title": sec["title"], "sort_order": new_order}}) except Exception as e: @@ -667,6 +673,7 @@ async def prd_duplicate_section( """, pid, new_slug, title, src["section_type"], src["sort_order"] + 1, src["status"], src["content"], src["summary"], src["tags"], src["notes"]) + await _record_activity(pool, pid, "prd_duplicate_section", {"from": section, "to": new_slug}) logger.info("Duplicated section: %s/%s → %s", project, section, new_slug) return ok({"duplicated": row_to_dict(row), "from": section}) except asyncpg.UniqueViolationError: @@ -708,6 +715,8 @@ async def prd_add_dependency( """, section, depends_on, dependency_type, description, project) if not row: return err(f"sections '{section}' and/or '{depends_on}' not found in project '{project}'") + pid = await resolve_project_id(pool, project) + await _record_activity(pool, pid, "prd_add_dependency", {"from": section, "to": depends_on}) logger.info("Added dependency: %s/%s → %s", project, section, depends_on) return ok({"dependency": {"from": section, "to": depends_on, "type": dependency_type}}) except Exception as e: @@ -732,6 +741,8 @@ async def prd_remove_dependency(project: str, section: str, depends_on: str) -> AND depends_on_id = (SELECT id FROM sections WHERE project_id = $1 AND slug = $3) """, pid, section, depends_on) removed = result.split()[-1] != "0" + if removed: + await _record_activity(pool, pid, "prd_remove_dependency", {"from": section, "to": depends_on}) return ok({"removed": removed, "from": section, "to": depends_on}) except Exception as e: logger.error("prd_remove_dependency: %s", e) @@ -802,6 +813,7 @@ async def prd_add_comment( INSERT INTO section_comments (section_id, anchor_text, anchor_prefix, anchor_suffix, body) VALUES ($1, $2, $3, $4, $5) RETURNING * """, sec["id"], anchor_text, anchor_prefix, anchor_suffix, body) + await _record_activity(pool, pid, "prd_add_comment", {"section": section}) logger.info("Added comment on %s/%s: %.40s", project, section, anchor_text) return ok({"created": row_to_dict(row)}) except Exception as e: @@ -843,6 +855,8 @@ async def prd_delete_comment(project: str, section: str, comment_id: str) -> str if not resolved: return err(f"comment '{comment_id}' not found in {project}/{section}") cid, _ = resolved + pid = await resolve_project_id(pool, project) + await _record_activity(pool, pid, "prd_delete_comment", {"section": section}) await pool.execute("DELETE FROM section_comments WHERE id = $1", cid) logger.info("Deleted comment %s on %s/%s", comment_id, project, section) return ok({"deleted": comment_id}) @@ -1390,6 +1404,8 @@ async def prd_bulk_status( else: updated.append(slug) + if updated: + await _record_activity(pool, pid, "prd_bulk_status", {"status": status, "count": len(updated)}) return ok({"status": status, "updated": updated, "not_found": not_found}) except Exception as e: logger.error("prd_bulk_status: %s", e) @@ -1398,6 +1414,17 @@ async def prd_bulk_status( # --- Group 9: Token Stats --- +async def _record_activity(pool, pid, tool_name: str, detail: dict | None = None): + """Record a write operation in mcp_activity for audit/dashboard.""" + try: + await pool.execute( + "INSERT INTO mcp_activity (project_id, tool_name, detail) VALUES ($1, $2, $3::jsonb)", + pid, tool_name, json.dumps(detail or {}), + ) + except Exception as e: + logger.warning("activity recording failed: %s", e) + + async def _record_token_estimate(pool, pid, operation: str, loaded_words: int): """Record a token estimate for tracking context savings.""" try: diff --git a/tests/conftest.py b/tests/conftest.py index c622c6d..f701711 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -26,6 +26,7 @@ async def db_pool(): async def clean_test_data(db_pool): """Clean up test-created data after each test, preserving seed data.""" async def _clean(): + await db_pool.execute("DELETE FROM mcp_activity") await db_pool.execute("DELETE FROM chat_messages") await db_pool.execute("DELETE FROM project_chats") await db_pool.execute("DELETE FROM comment_replies") diff --git a/tests/test_mcp_tools.py b/tests/test_mcp_tools.py index e4f4892..9054138 100644 --- a/tests/test_mcp_tools.py +++ b/tests/test_mcp_tools.py @@ -696,3 +696,72 @@ async def test_suggest_nonexistent_section(self, mcp_pool): project="snaphabit", section="nonexistent" )) assert "error" in result + + +class TestMcpActivity: + """Tests for MCP activity tracking on write operations.""" + + @staticmethod + def _detail(row): + """Parse JSONB detail — asyncpg returns JSONB as strings.""" + d = row["detail"] + return json.loads(d) if isinstance(d, str) else d + + async def test_create_project_records_activity(self, mcp_pool): + import server + await server.prd_create_project(name="Act Test", slug="act-test") + rows = await mcp_pool.fetch( + "SELECT tool_name, detail FROM mcp_activity WHERE tool_name = 'prd_create_project'" + ) + assert len(rows) >= 1 + assert self._detail(rows[-1])["slug"] == "act-test" + + async def test_create_section_records_activity(self, mcp_pool): + import server + await server.prd_create_project(name="Act Sec", slug="act-sec") + await server.prd_create_section(project="act-sec", slug="s1", title="S1") + rows = await mcp_pool.fetch( + "SELECT tool_name, detail FROM mcp_activity WHERE tool_name = 'prd_create_section'" + ) + assert len(rows) >= 1 + assert self._detail(rows[-1])["slug"] == "s1" + + async def test_update_section_records_activity(self, mcp_pool): + import server + await server.prd_create_project(name="Act Upd", slug="act-upd") + await server.prd_create_section(project="act-upd", slug="s1", title="S1", content="old") + await server.prd_update_section(project="act-upd", section="s1", content="new") + rows = await mcp_pool.fetch( + "SELECT detail FROM mcp_activity WHERE tool_name = 'prd_update_section'" + ) + assert len(rows) >= 1 + assert "content" in self._detail(rows[-1])["fields"] + + async def test_delete_section_records_activity(self, mcp_pool): + import server + await server.prd_create_project(name="Act Del", slug="act-del") + await server.prd_create_section(project="act-del", slug="ds1", title="DS1") + await server.prd_delete_section(project="act-del", section="ds1") + rows = await mcp_pool.fetch( + "SELECT detail FROM mcp_activity WHERE tool_name = 'prd_delete_section'" + ) + assert len(rows) >= 1 + assert self._detail(rows[-1])["slug"] == "ds1" + + async def test_activity_in_token_stats(self, mcp_pool): + """Token stats endpoint returns activity field.""" + import server + import app as ui_app + from httpx import ASGITransport, AsyncClient + + ui_app.pool = mcp_pool + transport = ASGITransport(app=ui_app.app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + await server.prd_create_project(name="Act Stats", slug="act-stats") + await server.prd_create_section(project="act-stats", slug="as1", title="AS1") + resp = await client.get("/api/projects/act-stats/token-stats") + assert resp.status_code == 200 + data = resp.json() + assert "activity" in data + assert isinstance(data["activity"], list) + assert len(data["activity"]) >= 1 diff --git a/ui/app.py b/ui/app.py index 3bf45c5..13c3456 100644 --- a/ui/app.py +++ b/ui/app.py @@ -1543,6 +1543,16 @@ async def get_token_stats(slug: str): pid, ) + # Recent MCP write activity + activity_rows = await pool.fetch(""" + SELECT tool_name, detail, created_at + FROM mcp_activity + WHERE project_id = $1 + ORDER BY created_at DESC + LIMIT 50 + """, pid) + activity = [row_dict(r) for r in activity_rows] + return { "operations": totals["operations"], "total_full_doc_tokens": total_full, @@ -1557,6 +1567,7 @@ async def get_token_stats(slug: str): "dependencies": project_stats["dependency_count"], "revisions": project_stats["revision_count"], }, + "activity": activity, } diff --git a/ui/static/index.html b/ui/static/index.html index ea6d5e6..f59b21e 100644 --- a/ui/static/index.html +++ b/ui/static/index.html @@ -487,7 +487,7 @@

Project Chat

-