|
| 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