ui-ticket-mcp 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,92 @@
1
+ Metadata-Version: 2.4
2
+ Name: ui-ticket-mcp
3
+ Version: 0.1.0
4
+ Summary: Review system MCP server for UI prototypes. Humans write comments in browser, AI agents resolve them via MCP.
5
+ Author: 0ics-srls
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/0ics-srls/ui-ticket-mcp
8
+ Project-URL: Repository, https://github.com/0ics-srls/ui-ticket-mcp
9
+ Project-URL: Issues, https://github.com/0ics-srls/ui-ticket-mcp/issues
10
+ Keywords: mcp,review,ui,prototype,ai-agent,fastapi
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Software Development :: Quality Assurance
20
+ Requires-Python: >=3.10
21
+ Description-Content-Type: text/markdown
22
+ Requires-Dist: mcp[cli]>=1.0.0
23
+ Requires-Dist: fastapi>=0.115.0
24
+ Requires-Dist: uvicorn[standard]>=0.30.0
25
+ Requires-Dist: aiosqlite>=0.20.0
26
+ Provides-Extra: dev
27
+ Requires-Dist: pytest; extra == "dev"
28
+ Requires-Dist: pytest-asyncio; extra == "dev"
29
+ Requires-Dist: httpx; extra == "dev"
30
+
31
+ # ui-ticket-mcp
32
+
33
+ Review system for UI prototypes. Humans write comments in the browser, AI agents resolve them via MCP.
34
+
35
+ ## Install
36
+
37
+ ```bash
38
+ pip install ui-ticket-mcp
39
+ ```
40
+
41
+ ## MCP Server (for AI agents)
42
+
43
+ Add to your `.mcp.json`:
44
+
45
+ ```json
46
+ {
47
+ "mcpServers": {
48
+ "review-system": {
49
+ "command": "python",
50
+ "args": ["-m", "review_mcp.mcp_server"],
51
+ "env": {
52
+ "PROJECT_ROOT": "/path/to/your/project"
53
+ }
54
+ }
55
+ }
56
+ }
57
+ ```
58
+
59
+ Reviews are stored in `{PROJECT_ROOT}/.reviews/reviews.db` (auto-created, committable to git).
60
+
61
+ ### MCP Tools
62
+
63
+ | Tool | Description |
64
+ |------|-------------|
65
+ | `get_review_summary()` | Summary of pages with open/resolved counts |
66
+ | `get_reviews(page_id?)` | List comments, optionally filtered by page |
67
+ | `add_review(page_id, author, text)` | Add a new comment |
68
+ | `resolve_review(review_id, resolved_by?)` | Mark as resolved |
69
+ | `reopen_review(review_id)` | Reopen a resolved review |
70
+ | `batch_resolve(page_id, resolved_by?)` | Resolve all open on a page |
71
+ | `get_pending_work()` | Open reviews grouped by page (todo list) |
72
+ | `find_source_file_tool(page_id)` | Find source files matching a page_id |
73
+
74
+ ## REST API (for browser UI)
75
+
76
+ ```bash
77
+ PROJECT_ROOT=/path/to/your/project python -m review_mcp.api
78
+ # → http://localhost:3200
79
+ ```
80
+
81
+ | Method | Endpoint | Description |
82
+ |--------|----------|-------------|
83
+ | GET | `/api/reviews/summary` | Per-page summary |
84
+ | GET | `/api/reviews` | All reviews |
85
+ | GET | `/api/reviews/{page_id}` | Reviews for a page |
86
+ | POST | `/api/reviews/{page_id}` | Add review |
87
+ | PATCH | `/api/review/{id}` | Update status/text |
88
+ | DELETE | `/api/review/{id}` | Delete review |
89
+
90
+ ## More
91
+
92
+ Full documentation: [github.com/0ics-srls/ui-ticket-mcp](https://github.com/0ics-srls/ui-ticket-mcp)
@@ -0,0 +1,62 @@
1
+ # ui-ticket-mcp
2
+
3
+ Review system for UI prototypes. Humans write comments in the browser, AI agents resolve them via MCP.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pip install ui-ticket-mcp
9
+ ```
10
+
11
+ ## MCP Server (for AI agents)
12
+
13
+ Add to your `.mcp.json`:
14
+
15
+ ```json
16
+ {
17
+ "mcpServers": {
18
+ "review-system": {
19
+ "command": "python",
20
+ "args": ["-m", "review_mcp.mcp_server"],
21
+ "env": {
22
+ "PROJECT_ROOT": "/path/to/your/project"
23
+ }
24
+ }
25
+ }
26
+ }
27
+ ```
28
+
29
+ Reviews are stored in `{PROJECT_ROOT}/.reviews/reviews.db` (auto-created, committable to git).
30
+
31
+ ### MCP Tools
32
+
33
+ | Tool | Description |
34
+ |------|-------------|
35
+ | `get_review_summary()` | Summary of pages with open/resolved counts |
36
+ | `get_reviews(page_id?)` | List comments, optionally filtered by page |
37
+ | `add_review(page_id, author, text)` | Add a new comment |
38
+ | `resolve_review(review_id, resolved_by?)` | Mark as resolved |
39
+ | `reopen_review(review_id)` | Reopen a resolved review |
40
+ | `batch_resolve(page_id, resolved_by?)` | Resolve all open on a page |
41
+ | `get_pending_work()` | Open reviews grouped by page (todo list) |
42
+ | `find_source_file_tool(page_id)` | Find source files matching a page_id |
43
+
44
+ ## REST API (for browser UI)
45
+
46
+ ```bash
47
+ PROJECT_ROOT=/path/to/your/project python -m review_mcp.api
48
+ # → http://localhost:3200
49
+ ```
50
+
51
+ | Method | Endpoint | Description |
52
+ |--------|----------|-------------|
53
+ | GET | `/api/reviews/summary` | Per-page summary |
54
+ | GET | `/api/reviews` | All reviews |
55
+ | GET | `/api/reviews/{page_id}` | Reviews for a page |
56
+ | POST | `/api/reviews/{page_id}` | Add review |
57
+ | PATCH | `/api/review/{id}` | Update status/text |
58
+ | DELETE | `/api/review/{id}` | Delete review |
59
+
60
+ ## More
61
+
62
+ Full documentation: [github.com/0ics-srls/ui-ticket-mcp](https://github.com/0ics-srls/ui-ticket-mcp)
@@ -0,0 +1,50 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68.0", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "ui-ticket-mcp"
7
+ version = "0.1.0"
8
+ description = "Review system MCP server for UI prototypes. Humans write comments in browser, AI agents resolve them via MCP."
9
+ requires-python = ">=3.10"
10
+ license = {text = "MIT"}
11
+ authors = [
12
+ {name = "0ics-srls"}
13
+ ]
14
+ readme = "README.md"
15
+ keywords = ["mcp", "review", "ui", "prototype", "ai-agent", "fastapi"]
16
+ classifiers = [
17
+ "Development Status :: 4 - Beta",
18
+ "Intended Audience :: Developers",
19
+ "License :: OSI Approved :: MIT License",
20
+ "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3.10",
22
+ "Programming Language :: Python :: 3.11",
23
+ "Programming Language :: Python :: 3.12",
24
+ "Programming Language :: Python :: 3.13",
25
+ "Topic :: Software Development :: Quality Assurance",
26
+ ]
27
+ dependencies = [
28
+ "mcp[cli]>=1.0.0",
29
+ "fastapi>=0.115.0",
30
+ "uvicorn[standard]>=0.30.0",
31
+ "aiosqlite>=0.20.0",
32
+ ]
33
+
34
+ [project.urls]
35
+ Homepage = "https://github.com/0ics-srls/ui-ticket-mcp"
36
+ Repository = "https://github.com/0ics-srls/ui-ticket-mcp"
37
+ Issues = "https://github.com/0ics-srls/ui-ticket-mcp/issues"
38
+
39
+ [project.optional-dependencies]
40
+ dev = [
41
+ "pytest",
42
+ "pytest-asyncio",
43
+ "httpx",
44
+ ]
45
+
46
+ [tool.pytest.ini_options]
47
+ asyncio_mode = "auto"
48
+
49
+ [tool.setuptools.packages.find]
50
+ include = ["review_mcp*"]
@@ -0,0 +1 @@
1
+ """Review system – MCP server + FastAPI REST API."""
@@ -0,0 +1,105 @@
1
+ """FastAPI REST API for the review system (serves the Angular UI)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from contextlib import asynccontextmanager
7
+ from typing import Optional
8
+
9
+ from fastapi import FastAPI, HTTPException
10
+ from fastapi.middleware.cors import CORSMiddleware
11
+ from pydantic import BaseModel
12
+
13
+ from . import db
14
+
15
+ REVIEW_PORT = int(os.environ.get("REVIEW_PORT", "3200"))
16
+
17
+
18
+ @asynccontextmanager
19
+ async def lifespan(_app: FastAPI):
20
+ await db.init_db()
21
+ yield
22
+
23
+
24
+ app = FastAPI(title="Review System API", lifespan=lifespan)
25
+
26
+ app.add_middleware(
27
+ CORSMiddleware,
28
+ allow_origins=["*"],
29
+ allow_methods=["*"],
30
+ allow_headers=["*"],
31
+ )
32
+
33
+
34
+ # --------------- Models ---------------
35
+
36
+
37
+ class ReviewCreate(BaseModel):
38
+ author: str = "anonymous"
39
+ text: str
40
+
41
+
42
+ class ReviewUpdate(BaseModel):
43
+ status: Optional[str] = None
44
+ text: Optional[str] = None
45
+ resolved_by: Optional[str] = None
46
+
47
+
48
+ # --------------- Endpoints ---------------
49
+
50
+
51
+ @app.get("/api/reviews/summary")
52
+ async def summary():
53
+ return await db.get_summary()
54
+
55
+
56
+ @app.get("/api/reviews")
57
+ async def all_reviews():
58
+ return await db.get_reviews()
59
+
60
+
61
+ @app.get("/api/reviews/{page_id:path}")
62
+ async def page_reviews(page_id: str):
63
+ return await db.get_reviews(page_id)
64
+
65
+
66
+ @app.post("/api/reviews/{page_id:path}", status_code=201)
67
+ async def create_review(page_id: str, body: ReviewCreate):
68
+ return await db.insert_review(page_id, body.author, body.text)
69
+
70
+
71
+ @app.patch("/api/review/{review_id}")
72
+ async def patch_review(review_id: int, body: ReviewUpdate):
73
+ fields: dict = {}
74
+ if body.status is not None:
75
+ fields["status"] = body.status
76
+ if body.status == "resolved":
77
+ from datetime import datetime, timezone
78
+ fields["resolved_at"] = datetime.now(timezone.utc).isoformat()
79
+ fields["resolved_by"] = body.resolved_by or "user"
80
+ elif body.status == "open":
81
+ fields["resolved_at"] = None
82
+ fields["resolved_by"] = None
83
+ if body.text is not None:
84
+ fields["text"] = body.text
85
+
86
+ result = await db.update_review(review_id, **fields)
87
+ if result is None:
88
+ raise HTTPException(404, "Review not found")
89
+ return result
90
+
91
+
92
+ @app.delete("/api/review/{review_id}")
93
+ async def remove_review(review_id: int):
94
+ ok = await db.delete_review(review_id)
95
+ if not ok:
96
+ raise HTTPException(404, "Review not found")
97
+ return {"deleted": True}
98
+
99
+
100
+ # --------------- Main ---------------
101
+
102
+ if __name__ == "__main__":
103
+ import uvicorn
104
+
105
+ uvicorn.run(app, host="0.0.0.0", port=REVIEW_PORT)
@@ -0,0 +1,206 @@
1
+ """SQLite database layer for review system (WAL mode, async)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from datetime import datetime, timezone
7
+ from pathlib import Path
8
+
9
+ import aiosqlite
10
+
11
+ REVIEWS_DIR = ".reviews"
12
+
13
+
14
+ def _resolve_db_path() -> str:
15
+ """Resolve DB path: REVIEW_DB_PATH env > PROJECT_ROOT/.reviews/reviews.db > ./reviews.db"""
16
+ explicit = os.environ.get("REVIEW_DB_PATH")
17
+ if explicit:
18
+ return explicit
19
+
20
+ project_root = os.environ.get("PROJECT_ROOT")
21
+ if project_root:
22
+ return str(Path(project_root) / REVIEWS_DIR / "reviews.db")
23
+
24
+ return "./reviews.db"
25
+
26
+
27
+ DB_PATH = _resolve_db_path()
28
+
29
+ SCHEMA = """
30
+ CREATE TABLE IF NOT EXISTS reviews (
31
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
32
+ page_id TEXT NOT NULL,
33
+ author TEXT NOT NULL DEFAULT 'anonymous',
34
+ text TEXT NOT NULL,
35
+ status TEXT NOT NULL DEFAULT 'open',
36
+ created_at TEXT NOT NULL,
37
+ resolved_at TEXT,
38
+ resolved_by TEXT
39
+ );
40
+
41
+ CREATE INDEX IF NOT EXISTS idx_reviews_page_id ON reviews(page_id);
42
+ CREATE INDEX IF NOT EXISTS idx_reviews_status ON reviews(status);
43
+ """
44
+
45
+
46
+ async def get_db() -> aiosqlite.Connection:
47
+ db = await aiosqlite.connect(DB_PATH)
48
+ await db.execute("PRAGMA journal_mode=WAL")
49
+ await db.execute("PRAGMA busy_timeout=5000")
50
+ db.row_factory = aiosqlite.Row
51
+ return db
52
+
53
+
54
+ async def init_db() -> None:
55
+ _ensure_reviews_dir()
56
+ db = await get_db()
57
+ try:
58
+ await db.executescript(SCHEMA)
59
+ await db.commit()
60
+ finally:
61
+ await db.close()
62
+
63
+
64
+ def _ensure_reviews_dir() -> None:
65
+ """Create .reviews/ directory with .gitkeep if it lives inside a PROJECT_ROOT."""
66
+ db_dir = Path(DB_PATH).parent
67
+ if db_dir.name != REVIEWS_DIR:
68
+ return
69
+
70
+ db_dir.mkdir(parents=True, exist_ok=True)
71
+
72
+ # .gitkeep so the directory is tracked in git
73
+ gitkeep = db_dir / ".gitkeep"
74
+ if not gitkeep.exists():
75
+ gitkeep.write_text("")
76
+
77
+ # .gitignore to track the DB but ignore WAL/SHM temp files
78
+ gitignore = db_dir / ".gitignore"
79
+ if not gitignore.exists():
80
+ gitignore.write_text("*.db-wal\n*.db-shm\n")
81
+
82
+
83
+ # --------------- Queries ---------------
84
+
85
+
86
+ async def get_summary() -> list[dict]:
87
+ """Return per-page summary with open/resolved counts."""
88
+ db = await get_db()
89
+ try:
90
+ sql = """
91
+ SELECT page_id,
92
+ SUM(CASE WHEN status = 'open' THEN 1 ELSE 0 END) AS open,
93
+ SUM(CASE WHEN status = 'resolved' THEN 1 ELSE 0 END) AS resolved,
94
+ COUNT(*) AS total
95
+ FROM reviews
96
+ GROUP BY page_id
97
+ ORDER BY page_id
98
+ """
99
+ rows = await db.execute_fetchall(sql)
100
+ return [dict(r) for r in rows]
101
+ finally:
102
+ await db.close()
103
+
104
+
105
+ async def get_reviews(page_id: str | None = None) -> list[dict]:
106
+ """Return reviews, optionally filtered by page_id."""
107
+ db = await get_db()
108
+ try:
109
+ if page_id:
110
+ rows = await db.execute_fetchall(
111
+ "SELECT * FROM reviews WHERE page_id = ? ORDER BY created_at DESC",
112
+ (page_id,),
113
+ )
114
+ else:
115
+ rows = await db.execute_fetchall(
116
+ "SELECT * FROM reviews ORDER BY created_at DESC"
117
+ )
118
+ return [dict(r) for r in rows]
119
+ finally:
120
+ await db.close()
121
+
122
+
123
+ async def insert_review(page_id: str, author: str, text: str) -> dict:
124
+ """Insert a new review comment and return it."""
125
+ db = await get_db()
126
+ try:
127
+ now = datetime.now(timezone.utc).isoformat()
128
+ cursor = await db.execute(
129
+ "INSERT INTO reviews (page_id, author, text, created_at) VALUES (?, ?, ?, ?)",
130
+ (page_id, author, text, now),
131
+ )
132
+ await db.commit()
133
+ row = await db.execute_fetchall(
134
+ "SELECT * FROM reviews WHERE id = ?", (cursor.lastrowid,)
135
+ )
136
+ return dict(row[0])
137
+ finally:
138
+ await db.close()
139
+
140
+
141
+ async def update_review(review_id: int, **fields) -> dict | None:
142
+ """Update review fields (status, text, resolved_at, resolved_by)."""
143
+ db = await get_db()
144
+ try:
145
+ allowed = {"status", "text", "resolved_at", "resolved_by"}
146
+ updates = {k: v for k, v in fields.items() if k in allowed}
147
+ if not updates:
148
+ return None
149
+
150
+ set_clause = ", ".join(f"{k} = ?" for k in updates)
151
+ values = list(updates.values()) + [review_id]
152
+
153
+ await db.execute(
154
+ f"UPDATE reviews SET {set_clause} WHERE id = ?", values
155
+ )
156
+ await db.commit()
157
+
158
+ row = await db.execute_fetchall(
159
+ "SELECT * FROM reviews WHERE id = ?", (review_id,)
160
+ )
161
+ return dict(row[0]) if row else None
162
+ finally:
163
+ await db.close()
164
+
165
+
166
+ async def delete_review(review_id: int) -> bool:
167
+ """Delete a review. Returns True if deleted."""
168
+ db = await get_db()
169
+ try:
170
+ cursor = await db.execute("DELETE FROM reviews WHERE id = ?", (review_id,))
171
+ await db.commit()
172
+ return cursor.rowcount > 0
173
+ finally:
174
+ await db.close()
175
+
176
+
177
+ async def get_open_reviews_by_page() -> list[dict]:
178
+ """Return open reviews grouped by page_id (for pending work)."""
179
+ db = await get_db()
180
+ try:
181
+ rows = await db.execute_fetchall(
182
+ "SELECT * FROM reviews WHERE status = 'open' ORDER BY page_id, created_at"
183
+ )
184
+ pages: dict[str, list[dict]] = {}
185
+ for r in rows:
186
+ d = dict(r)
187
+ pages.setdefault(d["page_id"], []).append(d)
188
+ return [{"page_id": pid, "reviews": revs} for pid, revs in pages.items()]
189
+ finally:
190
+ await db.close()
191
+
192
+
193
+ async def batch_resolve(page_id: str, resolved_by: str = "agent") -> int:
194
+ """Resolve all open reviews on a page. Returns count resolved."""
195
+ db = await get_db()
196
+ try:
197
+ now = datetime.now(timezone.utc).isoformat()
198
+ cursor = await db.execute(
199
+ "UPDATE reviews SET status = 'resolved', resolved_at = ?, resolved_by = ? "
200
+ "WHERE page_id = ? AND status = 'open'",
201
+ (now, resolved_by, page_id),
202
+ )
203
+ await db.commit()
204
+ return cursor.rowcount
205
+ finally:
206
+ await db.close()
@@ -0,0 +1,72 @@
1
+ """Find source files matching a page_id within a project root."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import glob
6
+ import os
7
+ import re
8
+
9
+
10
+ def _to_segments(page_id: str) -> list[str]:
11
+ """Split page_id into meaningful segments (by '-', '_', '/')."""
12
+ return [s for s in re.split(r"[-_/]", page_id) if s]
13
+
14
+
15
+ def _to_camel(segments: list[str]) -> str:
16
+ """Convert segments to CamelCase."""
17
+ return "".join(s.capitalize() for s in segments)
18
+
19
+
20
+ def _to_kebab(segments: list[str]) -> str:
21
+ """Convert segments to kebab-case."""
22
+ return "-".join(s.lower() for s in segments)
23
+
24
+
25
+ def find_source_file(page_id: str, project_root: str) -> list[str]:
26
+ """
27
+ Find source files matching a page_id.
28
+
29
+ Strategy:
30
+ 1. Try glob with segments joined: **/*seg1*seg2*
31
+ 2. Try CamelCase: **/*Seg1Seg2*
32
+ 3. Try kebab-case: **/*seg1-seg2*
33
+ 4. Try each segment individually if above fails
34
+
35
+ Returns list of matching file paths (relative to project_root).
36
+ """
37
+ if not project_root or not os.path.isdir(project_root):
38
+ return []
39
+
40
+ segments = _to_segments(page_id)
41
+ if not segments:
42
+ return []
43
+
44
+ candidates: set[str] = set()
45
+
46
+ # Pattern 1: segments joined with wildcards
47
+ joined_pattern = "*".join(s.lower() for s in segments)
48
+ # Pattern 2: CamelCase
49
+ camel = _to_camel(segments)
50
+ # Pattern 3: kebab-case
51
+ kebab = _to_kebab(segments)
52
+
53
+ patterns = [
54
+ f"**/*{joined_pattern}*",
55
+ f"**/*{camel}*",
56
+ f"**/*{kebab}*",
57
+ ]
58
+
59
+ for pattern in patterns:
60
+ for match in glob.glob(
61
+ os.path.join(project_root, pattern), recursive=True
62
+ ):
63
+ # Skip node_modules, dist, .git etc.
64
+ rel = os.path.relpath(match, project_root)
65
+ parts = rel.replace("\\", "/").split("/")
66
+ skip_dirs = {"node_modules", "dist", ".git", "__pycache__", ".angular"}
67
+ if any(p in skip_dirs for p in parts):
68
+ continue
69
+ if os.path.isfile(match):
70
+ candidates.add(rel.replace("\\", "/"))
71
+
72
+ return sorted(candidates)