Skip to content

Commit f8ab48a

Browse files
authored
Merge pull request #401 from PlanExeOrg/feature/plan-feedback-tool
feat: implement plan_feedback MCP tool
2 parents 093bc1d + ca8236b commit f8ab48a

File tree

11 files changed

+603
-2
lines changed

11 files changed

+603
-2
lines changed

database_api/model_feedback.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
"""Feedback submitted by MCP clients about plan quality, workflow, or the interface."""
2+
import uuid
3+
from datetime import datetime, UTC
4+
from database_api.planexe_db_singleton import db
5+
6+
7+
class FeedbackItem(db.Model):
8+
__tablename__ = "feedback_item"
9+
10+
# Server-generated UUID.
11+
id = db.Column(db.String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
12+
13+
# When the feedback was received (UTC).
14+
received_at = db.Column(db.DateTime, nullable=False, default=lambda: datetime.now(UTC), index=True)
15+
16+
# Feedback category (one of the 12 defined categories).
17+
category = db.Column(db.String(32), nullable=False)
18+
19+
# Free-text feedback message.
20+
message = db.Column(db.Text, nullable=False)
21+
22+
# Optional plan UUID this feedback is about.
23+
plan_id = db.Column(db.String(36), nullable=True)
24+
25+
# Optional satisfaction score (1-5).
26+
rating = db.Column(db.Integer, nullable=True)
27+
28+
# Optional severity for issue reports (low/medium/high).
29+
severity = db.Column(db.String(8), nullable=True)
30+
31+
# User who submitted the feedback (resolved from auth context).
32+
user_id = db.Column(db.String(36), nullable=True)
33+
34+
# Inline snapshot of the plan state at feedback time.
35+
plan_progress_pct = db.Column(db.Float, nullable=True)
36+
plan_state = db.Column(db.String(16), nullable=True)
37+
plan_current_step = db.Column(db.String(128), nullable=True)
38+
39+
def __repr__(self):
40+
return f"<FeedbackItem(id={self.id}, category='{self.category}', received_at='{self.received_at}')>"
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
# Design: `plan_feedback` MCP Tool
2+
3+
**Date:** 2026-03-28
4+
**Proposal:** [127-mcp-feedback.md](../../proposals/127-mcp-feedback.md)
5+
6+
## Summary
7+
8+
Add a `plan_feedback` MCP tool that allows LLM consumers to submit structured feedback about the PlanExe MCP interface, plan quality, and workflow experiences. Feedback is stored in PostgreSQL for later analysis. The tool is non-blocking and fire-and-forget: it always returns success to the caller even if storage fails internally.
9+
10+
## Parameters
11+
12+
| Parameter | Type | Required | Description |
13+
|-----------|------|----------|-------------|
14+
| `category` | enum | yes | One of: `sse_issue`, `status_staleness`, `queue_delay`, `file_visibility`, `plan_quality`, `tool_description`, `workflow`, `performance`, `error_handling`, `suggestion`, `compliment`, `other` |
15+
| `message` | string | yes | Free-text feedback (concise, actionable) |
16+
| `plan_id` | string\|null | no | UUID to attach feedback to a specific plan |
17+
| `rating` | integer 1-5\|null | no | Satisfaction score |
18+
| `severity` | enum\|null | no | `low`, `medium`, or `high` (for issue reports) |
19+
20+
## Response
21+
22+
### Success (always returned to caller)
23+
24+
```json
25+
{
26+
"feedback_id": "uuid",
27+
"received_at": "2026-03-28T14:30:00Z",
28+
"message": "Feedback received. Thank you."
29+
}
30+
```
31+
32+
### Validation Errors
33+
34+
```json
35+
{
36+
"error": {
37+
"code": "INVALID_FEEDBACK",
38+
"message": "Human-readable validation error"
39+
}
40+
}
41+
```
42+
43+
```json
44+
{
45+
"error": {
46+
"code": "PLAN_NOT_FOUND",
47+
"message": "Plan not found: <plan_id>"
48+
}
49+
}
50+
```
51+
52+
## Database Table: `feedback_item`
53+
54+
| Column | Type | Notes |
55+
|--------|------|-------|
56+
| `id` | VARCHAR(36) | PK, UUID generated server-side |
57+
| `received_at` | TIMESTAMP | UTC, indexed, default now() |
58+
| `category` | VARCHAR(32) | One of 12 enum values |
59+
| `message` | TEXT | Free-text feedback |
60+
| `plan_id` | VARCHAR(36) | Nullable, references task_item(id) |
61+
| `rating` | INTEGER | Nullable, 1-5 |
62+
| `severity` | VARCHAR(8) | Nullable: low/medium/high |
63+
| `user_id` | VARCHAR(36) | Nullable, resolved from auth context |
64+
| `plan_progress_pct` | FLOAT | Nullable, snapshot at feedback time |
65+
| `plan_state` | VARCHAR(16) | Nullable, snapshot at feedback time |
66+
| `plan_current_step` | VARCHAR(128) | Nullable, snapshot at feedback time |
67+
68+
No foreign key constraint on `plan_id` to keep writes simple and avoid blocking on plan table locks.
69+
70+
## Files to Create/Modify
71+
72+
### New File
73+
- `database_api/model_feedback.py` — SQLAlchemy model `FeedbackItem`
74+
75+
### Modified Files
76+
- `mcp_cloud/tool_models.py` — Add `PlanFeedbackInput`, `PlanFeedbackOutput` Pydantic models
77+
- `mcp_cloud/schemas.py` — Add schema constants and `ToolDefinition` entry
78+
- `mcp_cloud/handlers.py` — Add `handle_plan_feedback()`, register in `TOOL_HANDLERS`
79+
- `mcp_cloud/db_setup.py` — Import model, add `PlanFeedbackRequest`, update `PLANEXE_SERVER_INSTRUCTIONS`
80+
- `mcp_cloud/db_queries.py` — Add `_create_feedback_sync()` and `_get_plan_snapshot_for_feedback_sync()`
81+
82+
## Handler Logic
83+
84+
1. Parse and validate input via `PlanFeedbackRequest` (Pydantic BaseModel)
85+
2. If `plan_id` provided:
86+
- Look up plan via `_get_plan_snapshot_for_feedback_sync()`
87+
- If not found, return `PLAN_NOT_FOUND` error (this is the only error visible to caller)
88+
- If found, capture snapshot: `progress_percentage`, `state`, `current_step`
89+
3. Generate `feedback_id` (UUID4) and `received_at` (UTC now)
90+
4. Write to DB in try/except:
91+
- On success: return `{feedback_id, received_at, message}`
92+
- On failure: **log the error**, still return success response (fire-and-forget)
93+
5. Response is always `isError=False` except for `PLAN_NOT_FOUND` and `INVALID_FEEDBACK`
94+
95+
## Tool Annotations
96+
97+
```python
98+
annotations={
99+
"readOnlyHint": False, # writes to DB
100+
"destructiveHint": False, # no destructive side effects
101+
"idempotentHint": True, # duplicate submissions are safe
102+
"openWorldHint": False, # no external calls
103+
}
104+
```
105+
106+
## Server Instructions Update
107+
108+
Add to `PLANEXE_SERVER_INSTRUCTIONS`:
109+
> "Use plan_feedback to report issues or share observations about plan quality, workflow friction, or the MCP interface. Feedback is fire-and-forget and never blocks the workflow."
110+
111+
## Behavioral Guarantees
112+
113+
- Non-blocking: handler returns in <1 second
114+
- Fire-and-forget: never gates workflow
115+
- No rate limiting on LLM consumers
116+
- Always returns success to caller (except validation/plan-not-found errors)
117+
- Internal storage failures are logged but not surfaced to caller
118+
119+
## Testing
120+
121+
Follow existing test patterns (e.g., `test_plan_create_tool.py`):
122+
- Valid feedback with all fields
123+
- Valid feedback with only required fields
124+
- Invalid category returns INVALID_FEEDBACK
125+
- Invalid plan_id returns PLAN_NOT_FOUND
126+
- Rating out of range returns INVALID_FEEDBACK
127+
- Invalid severity returns INVALID_FEEDBACK
128+
- DB write failure still returns success (fire-and-forget)

mcp_cloud/app.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
PlanResumeRequest,
3535
PlanFileInfoRequest,
3636
PlanListRequest,
37+
PlanFeedbackRequest,
3738
ModelProfilesRequest,
3839
PlanItem,
3940
PlanState,
@@ -63,6 +64,8 @@
6364
_resume_plan_sync,
6465
_get_plan_for_report_sync,
6566
_list_plans_sync,
67+
_get_plan_snapshot_for_feedback_sync,
68+
_create_feedback_sync,
6669
get_plan_state_mapping,
6770
_extract_plan_create_metadata_overrides,
6871
_merge_plan_create_config,
@@ -144,6 +147,8 @@
144147
EXAMPLE_PLANS_OUTPUT_SCHEMA,
145148
PLAN_LIST_INPUT_SCHEMA,
146149
PLAN_LIST_OUTPUT_SCHEMA,
150+
PLAN_FEEDBACK_INPUT_SCHEMA,
151+
PLAN_FEEDBACK_OUTPUT_SCHEMA,
147152
ToolDefinition,
148153
TOOL_DEFINITIONS,
149154
)
@@ -162,6 +167,7 @@
162167
handle_plan_resume,
163168
handle_plan_file_info,
164169
handle_plan_list,
170+
handle_plan_feedback,
165171
TOOL_HANDLERS,
166172
)
167173

mcp_cloud/db_queries.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
from mcp_cloud.db_setup import app, db, PlanItem, PlanState, EventItem, EventType
1616
from database_api.model_credit_history import CreditHistory
17+
from database_api.model_feedback import FeedbackItem
1718

1819
logger = logging.getLogger(__name__)
1920

@@ -483,6 +484,67 @@ def _list_plans_sync(user_id: Optional[str], limit: int) -> list[dict[str, Any]]
483484
return results
484485

485486

487+
# ---------------------------------------------------------------------------
488+
# Feedback
489+
# ---------------------------------------------------------------------------
490+
491+
def _get_plan_snapshot_for_feedback_sync(plan_id: str) -> Optional[dict[str, Any]]:
492+
"""Return a lightweight plan snapshot for embedding in feedback, or None if not found."""
493+
with app.app_context():
494+
try:
495+
plan_uuid = uuid.UUID(plan_id)
496+
except ValueError:
497+
return None
498+
row = (
499+
db.session.query(
500+
PlanItem.id,
501+
PlanItem.state,
502+
PlanItem.progress_percentage,
503+
PlanItem.current_step,
504+
)
505+
.filter(PlanItem.id == plan_uuid)
506+
.first()
507+
)
508+
if row is None:
509+
return None
510+
return {
511+
"plan_id": str(row.id),
512+
"state": get_plan_state_mapping(row.state),
513+
"progress_percentage": float(row.progress_percentage or 0.0),
514+
"current_step": row.current_step,
515+
}
516+
517+
518+
def _create_feedback_sync(
519+
feedback_id: str,
520+
received_at: datetime,
521+
category: str,
522+
message: str,
523+
plan_id: Optional[str],
524+
rating: Optional[int],
525+
severity: Optional[str],
526+
user_id: Optional[str],
527+
plan_snapshot: Optional[dict[str, Any]],
528+
) -> None:
529+
"""Persist a feedback entry to the database. Raises on failure."""
530+
with app.app_context():
531+
item = FeedbackItem(
532+
id=feedback_id,
533+
received_at=received_at,
534+
category=category,
535+
message=message,
536+
plan_id=plan_id,
537+
rating=rating,
538+
severity=severity,
539+
user_id=user_id,
540+
plan_progress_pct=plan_snapshot["progress_percentage"] if plan_snapshot else None,
541+
plan_state=plan_snapshot["state"] if plan_snapshot else None,
542+
plan_current_step=plan_snapshot["current_step"] if plan_snapshot else None,
543+
)
544+
db.session.add(item)
545+
db.session.commit()
546+
547+
486548
# ---------------------------------------------------------------------------
487549
# Utilities
488550
# ---------------------------------------------------------------------------

mcp_cloud/db_setup.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
from database_api.model_user_api_key import UserApiKey
3939
from database_api.model_credit_history import CreditHistory # noqa: F401
4040
from database_api.model_token_metrics import TokenMetrics # noqa: F401
41+
from database_api.model_feedback import FeedbackItem # noqa: F401
4142

4243
print("[startup] db_setup.py: creating Flask app", file=sys.stderr, flush=True)
4344
app = Flask(__name__)
@@ -232,6 +233,8 @@ def ensure_last_progress_at_column() -> None:
232233
"In both cases, report the issue to PlanExe developers on GitHub: https://github.com/PlanExeOrg/PlanExe/issues . "
233234
"Main output: a self-contained interactive HTML report (~700KB) with collapsible sections and interactive Gantt charts — open in a browser. "
234235
"The zip contains the intermediary pipeline files (md, json, csv) that fed the report. "
236+
"Use plan_feedback to report issues or share observations about plan quality, workflow friction, or the MCP interface. "
237+
"Feedback is fire-and-forget and never blocks the workflow. "
235238
"New users: create an account and obtain an API key at https://home.planexe.org/ ."
236239
)
237240

@@ -298,3 +301,10 @@ class PlanListRequest(BaseModel):
298301
class ModelProfilesRequest(BaseModel):
299302
"""No input parameters."""
300303
pass
304+
305+
class PlanFeedbackRequest(BaseModel):
306+
category: str
307+
message: str
308+
plan_id: Optional[str] = None
309+
rating: Optional[int] = None
310+
severity: Optional[str] = None

0 commit comments

Comments
 (0)