Skip to content

Commit 99f66b7

Browse files
authored
feat: fastmcp 3.4.2 uplift — annotations, structured output, resources, prompts, context (#34)
* feat: fastmcp 3.4.2 uplift — annotations, structured output, resources, prompts, context * feat: deepen tool annotations — titles + read/write/destructive tags across all 32 tools Add per-tool human-friendly ToolAnnotations.title and coarse capability tags (read/write/destructive) to every tool registration. Semantics: readOnlyHint on get-*/search-*/show-*/review/insights; destructiveHint on delete-area/merge-areas/bulk-cancel; complete-task stays idempotent (not destructive — reversible in Things 3); idempotentHint on modify/schedule/ defer; openWorldHint=False everywhere (Things is a local macOS app). Additive only: no tool renames, no parameter changes, no output_schema changes. Adds tests/test_tool_annotations.py contract tests.
1 parent 0b90c12 commit 99f66b7

12 files changed

Lines changed: 809 additions & 55 deletions

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ classifiers = [
3333
]
3434
dependencies = [
3535
"httpx>=0.28.1",
36-
"fastmcp>=3.2.4",
36+
"fastmcp>=3.4.2,<4.0.0",
3737
"pydantic-settings>=2.12.0",
3838
"things-py>=1.0.1",
3939
"rich>=14.0.0",

src/things_mcp/fast_server.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@
4848
from .tools_gtd_reflect import register_gtd_reflect_tools
4949
from .tools_utility import register_utility_tools
5050
from .tools_batch import register_batch_tools
51+
from .resources import register_resources
52+
from .prompts import register_prompts
5153

5254
# Configure enhanced logging
5355
# Console shows DEBUG if THINGS_MCP_DEBUG=true, otherwise INFO
@@ -68,6 +70,10 @@
6870
register_utility_tools(mcp) # Utility (search, list, show, cache)
6971
register_batch_tools(mcp) # Batch (bulk-capture, bulk-complete, etc.)
7072

73+
# Register ambient GTD-state resources and guided-workflow prompts
74+
register_resources(mcp) # things:// resources (inbox count, today, stalled, etc.)
75+
register_prompts(mcp) # Guided GTD prompts (weekly-review, process-inbox, plan)
76+
7177

7278
def _print_shutdown_summary():
7379
"""Print a beautiful shutdown summary with stats."""

src/things_mcp/prompts.py

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
"""MCP prompts: guided, selectable GTD workflows.
2+
3+
These wrap the server's signature multi-step rituals — the weekly review, inbox
4+
processing to zero, and project planning — into first-class, discoverable
5+
``@mcp.prompt`` entries. The narrative guidance previously lived only in tool
6+
docstrings and server instructions; exposing it as prompts lets a client surface
7+
the GTD ritual as a single click and have the model walk it step by step using
8+
the existing tools.
9+
10+
Each prompt returns a plain string, which FastMCP delivers as the initial user
11+
message of the workflow.
12+
"""
13+
14+
from __future__ import annotations
15+
16+
from fastmcp import FastMCP
17+
18+
19+
def register_prompts(mcp: FastMCP) -> None:
20+
"""Register guided GTD-workflow prompts with the server."""
21+
22+
@mcp.prompt(
23+
name="weekly-review",
24+
title="Run a GTD Weekly Review",
25+
description=(
26+
"Guide me through David Allen's weekly review: get current, get "
27+
"clear, get creative. Uses weekly-review, process-inbox, and the "
28+
"bulk-* tools."
29+
),
30+
tags={"reflect", "workflow"},
31+
)
32+
def weekly_review_prompt() -> str:
33+
return (
34+
"Walk me through a complete GTD weekly review for my Things 3 setup. "
35+
"Follow these steps and use the matching tools:\n\n"
36+
"1. **Get current** — Call `weekly-review` to surface stalled "
37+
"projects, waiting-for items, someday/maybe, and what I completed.\n"
38+
"2. **Get clear** — If the inbox is not empty, call "
39+
"`process-inbox(all=True)`, then propose a single `bulk-triage` call "
40+
"with a GTD decision (do / defer / delegate / delete / assign) for "
41+
"each item. Show me the plan before executing.\n"
42+
"3. **Get projects moving** — For every stalled project, suggest a "
43+
"concrete next action and offer to add it.\n"
44+
"4. **Review the horizons** — Walk the someday/maybe list and ask "
45+
"which items should become active now.\n"
46+
"5. **Wrap up** — Summarise what changed and what still needs my "
47+
"attention.\n\n"
48+
"Pause for my confirmation before any write that completes, cancels, "
49+
"or reschedules tasks."
50+
)
51+
52+
@mcp.prompt(
53+
name="process-inbox-to-zero",
54+
title="Process the Inbox to Zero",
55+
description=(
56+
"Clarify every inbox item with the GTD decision tree and clear the "
57+
"inbox in one batch. Uses process-inbox and bulk-triage."
58+
),
59+
tags={"clarify", "workflow"},
60+
)
61+
def process_inbox_prompt() -> str:
62+
return (
63+
"Help me process my Things inbox to zero using the GTD clarify "
64+
"workflow:\n\n"
65+
"1. Call `process-inbox(all=True)` to load every inbox item with its "
66+
"suggested category.\n"
67+
"2. For each item, apply the GTD decision tree:\n"
68+
" - Not actionable → **delete** (cancel) or **defer** to "
69+
"someday/maybe.\n"
70+
" - Actionable, < 2 minutes → flag it so I can **do it now**.\n"
71+
" - Actionable, mine, later → **schedule** or **defer** with a "
72+
"`when`.\n"
73+
" - Actionable, someone else's → **delegate** (capture who).\n"
74+
" - Multi-step outcome → **assign** to a project.\n"
75+
"3. Assemble one `bulk-triage` call with a decision per item and show "
76+
"it to me for approval before executing.\n"
77+
"4. After triage, confirm the inbox is empty and report the action "
78+
"breakdown.\n\n"
79+
"Ask me whenever an item's correct decision is genuinely ambiguous."
80+
)
81+
82+
@mcp.prompt(
83+
name="plan-project",
84+
title="Plan a New Project",
85+
description=(
86+
"Turn a desired outcome into a Things project with a clean next "
87+
"action and supporting tasks. Uses plan-project."
88+
),
89+
tags={"organize", "workflow"},
90+
)
91+
def plan_project_prompt(outcome: str) -> str:
92+
return (
93+
f"I want to plan a project for this outcome: {outcome}\n\n"
94+
"Apply GTD natural planning:\n"
95+
"1. Restate the **successful outcome** in one sentence (what 'done' "
96+
"looks like).\n"
97+
"2. Brainstorm the concrete tasks needed to get there.\n"
98+
"3. Identify the single **next action** — the very next physical, "
99+
"visible step — and put it first.\n"
100+
"4. Suggest a GTD context tag (@computer, @phone, …) for each task "
101+
"and an area of focus for the project.\n"
102+
"5. Show me the proposed project and task list, then call "
103+
"`plan-project` to create it atomically once I approve."
104+
)

src/things_mcp/resources.py

Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
"""MCP resources: ambient, read-only GTD context under the ``things://`` scheme.
2+
3+
Resources let an agent passively pull current GTD state (inbox count, today's
4+
list, stalled projects, triage stats) and static reference data (the GTD context
5+
tag taxonomy, server config) *without spending a tool call*. They mirror data the
6+
review tools already compute — they just expose it as addressable, cacheable
7+
context the model can read on its own initiative.
8+
9+
All resources are read-only by definition. Mutations still go through the
10+
existing ``@mcp.tool`` write tools.
11+
"""
12+
13+
from __future__ import annotations
14+
15+
import json
16+
from datetime import date
17+
from typing import Any
18+
19+
from fastmcp import FastMCP
20+
21+
from . import reader as db
22+
from .logging_config import get_logger
23+
from .settings import get_settings, get_transport
24+
from .triage_tracker import triage_tracker
25+
26+
logger = get_logger(__name__)
27+
28+
29+
# GTD context tags shipped as a discoverable taxonomy. Mirrors the contexts
30+
# documented in the server instructions and CLAUDE.md.
31+
GTD_CONTEXT_TAGS: list[dict[str, str]] = [
32+
{"tag": "@computer", "meaning": "Anything requiring a computer / desk."},
33+
{"tag": "@phone", "meaning": "Calls or tasks done on the phone."},
34+
{"tag": "@office", "meaning": "Requires being at the office."},
35+
{"tag": "@home", "meaning": "Requires being at home."},
36+
{"tag": "@errands", "meaning": "Out-and-about tasks (shops, post, bank)."},
37+
{"tag": "@anywhere", "meaning": "Context-free; can be done from anywhere."},
38+
{
39+
"tag": "waiting-for",
40+
"meaning": "Delegated — awaiting someone else (see delegate-task).",
41+
},
42+
]
43+
44+
45+
def _stalled_projects(today_str: str) -> list[dict[str, str]]:
46+
"""Return incomplete projects that have no available next action.
47+
48+
Mirrors the weekly-review stalled-project computation: a project is stalled
49+
when none of its incomplete to-dos are available (anytime / today / no start).
50+
"""
51+
from collections import defaultdict
52+
53+
projects = db.projects() or []
54+
all_incomplete = db.todos(status="incomplete") or []
55+
56+
todos_by_project: dict[str, list[dict[str, Any]]] = defaultdict(list)
57+
for t in all_incomplete:
58+
proj_uuid = t.get("project")
59+
if proj_uuid:
60+
todos_by_project[proj_uuid].append(t)
61+
62+
stalled: list[dict[str, str]] = []
63+
for project in projects:
64+
if project.get("status") != "incomplete":
65+
continue
66+
proj_todos = todos_by_project.get(project.get("uuid"), [])
67+
available = [
68+
t
69+
for t in proj_todos
70+
if t.get("start") in (None, "Anytime", "Today")
71+
or t.get("start_date") is None
72+
or t.get("start_date") == today_str
73+
]
74+
if not available:
75+
stalled.append(
76+
{"uuid": project.get("uuid", ""), "title": project.get("title", "")}
77+
)
78+
return stalled
79+
80+
81+
def register_resources(mcp: FastMCP) -> None:
82+
"""Register ambient GTD-state and reference resources with the server."""
83+
84+
@mcp.resource(
85+
"things://inbox/count",
86+
name="inbox-count",
87+
title="Inbox Count",
88+
description="Number of unclarified items currently in the Things inbox.",
89+
mime_type="application/json",
90+
tags={"reflect", "ambient"},
91+
)
92+
def inbox_count() -> str:
93+
inbox = db.inbox() or []
94+
return json.dumps({"inbox_count": len(inbox)})
95+
96+
@mcp.resource(
97+
"things://today",
98+
name="today",
99+
title="Today's Tasks",
100+
description=(
101+
"Today's scheduled tasks plus overdue items — the GTD 'hard "
102+
"landscape' for right now. Read this to orient before planning a day."
103+
),
104+
mime_type="application/json",
105+
tags={"engage", "reflect", "ambient"},
106+
)
107+
def today() -> str:
108+
today_str = date.today().isoformat()
109+
today_tasks = db.today() or []
110+
all_todos = db.todos(status="incomplete") or []
111+
overdue = [
112+
{"title": t.get("title", ""), "deadline": t.get("deadline")}
113+
for t in all_todos
114+
if t.get("deadline") and t.get("deadline") < today_str
115+
]
116+
return json.dumps(
117+
{
118+
"date": today_str,
119+
"today_count": len(today_tasks),
120+
"overdue_count": len(overdue),
121+
"today": [t.get("title", "") for t in today_tasks],
122+
"overdue": overdue,
123+
}
124+
)
125+
126+
@mcp.resource(
127+
"things://stalled-projects",
128+
name="stalled-projects",
129+
title="Stalled Projects",
130+
description=(
131+
"Incomplete projects with no available next action — the core "
132+
"weekly-review signal. Each stalled project needs a next action added."
133+
),
134+
mime_type="application/json",
135+
tags={"reflect", "organize", "ambient"},
136+
)
137+
def stalled_projects() -> str:
138+
today_str = date.today().isoformat()
139+
stalled = _stalled_projects(today_str)
140+
return json.dumps({"stalled_count": len(stalled), "projects": stalled})
141+
142+
@mcp.resource(
143+
"things://triage/stats",
144+
name="triage-stats",
145+
title="Triage Stats (7 days)",
146+
description=(
147+
"Anonymised triage activity over the last 7 days: totals, action "
148+
"breakdown, busiest day, and the no-context cancel rate. No task "
149+
"titles or content are stored."
150+
),
151+
mime_type="application/json",
152+
tags={"reflect", "ambient", "stats"},
153+
)
154+
def triage_stats() -> str:
155+
try:
156+
summary = triage_tracker.get_summary(days=7)
157+
except Exception:
158+
logger.debug("triage stats resource failed (non-critical)", exc_info=True)
159+
summary = {"total": 0}
160+
return json.dumps(summary)
161+
162+
@mcp.resource(
163+
"things://contexts",
164+
name="gtd-contexts",
165+
title="GTD Context Tags",
166+
description=(
167+
"The GTD context-tag taxonomy this server recognises (@computer, "
168+
"@phone, …). Use these with get-tasks(context=…) to filter by where "
169+
"or how a task can be done."
170+
),
171+
mime_type="application/json",
172+
tags={"reference", "taxonomy"},
173+
)
174+
def gtd_contexts() -> str:
175+
return json.dumps({"contexts": GTD_CONTEXT_TAGS})
176+
177+
@mcp.resource(
178+
"things://config",
179+
name="server-config",
180+
title="Server Config",
181+
description=(
182+
"Non-secret server configuration: host, port, transport, and "
183+
"whether a Things auth token is configured (needed for writes). "
184+
"Secrets are never exposed."
185+
),
186+
mime_type="application/json",
187+
tags={"reference", "status"},
188+
)
189+
def server_config() -> str:
190+
settings = get_settings()
191+
try:
192+
from . import __version__ as version
193+
except ImportError:
194+
version = "unknown"
195+
token = settings.things_auth_token.get_secret_value()
196+
return json.dumps(
197+
{
198+
"version": version,
199+
"host": settings.things_mcp_host,
200+
"port": settings.things_mcp_port,
201+
"transport": get_transport(),
202+
"auth_token_configured": bool(token),
203+
}
204+
)

0 commit comments

Comments
 (0)