Skip to content

Commit 9e24deb

Browse files
bobbyhyamclaude
andcommitted
Surface task location on reads and add parent_task_id to update_task
format_task now returns the Location property on all task reads (search, snapshot, bulk, complete, etc.) when the Tasks DB defines one. update_task gains a parent_task_id parameter, matching create_task and bulk_update_tasks. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent ccd15b6 commit 9e24deb

5 files changed

Lines changed: 134 additions & 15 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ Six required env vars: `NOTION_SECRET`, `TASKS_DS_ID`, `PROJECTS_DS_ID`, `NOTES_
4545

4646
**Source layout:** `src/ultimate_brain_mcp/` with four modules:
4747

48-
- **`server.py`** — All 30 MCP tool definitions using `@mcp.tool()` decorators. Tools are grouped: Tasks (6), Projects (4), Notes (4), Tags (3), Goals (4), Cross-cutting (3: `daily_summary`, `archive_item`, `set_page_content`), Workflow consolidators (2: `daily_review_snapshot`, `bulk_update_tasks`), Generic (4: `query_database`, `get_page`, `get_page_content`, `update_page`). The 4 create tools (`create_task`, `create_note`, `create_project`, `create_goal`) accept an optional `content` parameter for page body content. `create_task` and `update_task` also accept optional `tag_ids` (Tag relation) and `location` (auto-detected from the live Tasks schema). `bulk_update_tasks` applies up to N task patches concurrently with per-row results, never raising on a single failure. Each tool uses `ToolAnnotations` to declare read-only vs destructive. Property builder helpers (`_prop_title`, `_prop_select`, `_build_location_payload`, etc.) construct Notion API property payloads. `_coerce_property()` auto-converts Python types to Notion property format for the generic `update_page` tool.
48+
- **`server.py`** — All 30 MCP tool definitions using `@mcp.tool()` decorators. Tools are grouped: Tasks (6), Projects (4), Notes (4), Tags (3), Goals (4), Cross-cutting (3: `daily_summary`, `archive_item`, `set_page_content`), Workflow consolidators (2: `daily_review_snapshot`, `bulk_update_tasks`), Generic (4: `query_database`, `get_page`, `get_page_content`, `update_page`). The 4 create tools (`create_task`, `create_note`, `create_project`, `create_goal`) accept an optional `content` parameter for page body content. `create_task` and `update_task` also accept optional `parent_task_id` (Parent Task relation), `tag_ids` (Tag relation), and `location` (auto-detected from the live Tasks schema). `format_task` surfaces `location` on every task read (search/get/snapshot/bulk results) when the Tasks DB has a Location property — callers pass the discovered property name from `tasks_schema`. `bulk_update_tasks` applies up to N task patches concurrently with per-row results, never raising on a single failure. Each tool uses `ToolAnnotations` to declare read-only vs destructive. Property builder helpers (`_prop_title`, `_prop_select`, `_build_location_payload`, etc.) construct Notion API property payloads. `_coerce_property()` auto-converts Python types to Notion property format for the generic `update_page` tool.
4949

5050
- **`notion_client.py`** — Async httpx wrapper around Notion API v2025-09-03. Uses the data_sources query endpoint (not legacy database queries). Core methods: `query_all()` (paginated), `create_page()` (supports `children` for inline body content), `get_page()`, `update_page()`, `get_blocks()`, `append_blocks()`, `delete_block()`. Raises `NotionAPIError` with status-specific hints.
5151

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "ultimate-brain-mcp"
3-
version = "0.5.0"
3+
version = "0.5.1"
44
description = "MCP server for Thomas Frank's Ultimate Brain Notion system"
55
readme = "README.md"
66
license = "MIT"

src/ultimate_brain_mcp/formatters.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,7 @@ def format_task(
137137
*,
138138
project_lookup: dict[str, dict] | None = None,
139139
tag_lookup: dict[str, dict] | None = None,
140+
location_property_name: str | None = None,
140141
) -> dict:
141142
"""Format a Task page into an agent-friendly dict.
142143
@@ -145,6 +146,12 @@ def format_task(
145146
*tag_lookup* is supplied, ``area_tag_names`` is populated for any Tag
146147
relations whose IDs appear in the lookup. Existing callers that pass
147148
nothing keep the legacy shape — IDs only, no resolved names.
149+
150+
When *location_property_name* is supplied (the dynamically-discovered Tasks
151+
Location property name), the result carries ``location``. The shape mirrors
152+
how it is stored: a string for ``select`` / ``status`` properties, a list
153+
for ``multi_select``. Absent when no name is passed, the property is missing
154+
from the page, or its value is empty.
148155
"""
149156
props = page.get("properties", {})
150157
result: dict = {
@@ -186,6 +193,22 @@ def format_task(
186193
labels = _multi_select(props.get("Labels", {}))
187194
if labels:
188195
result["labels"] = labels
196+
# Location — dynamic property name discovered at boot. Dispatch on the
197+
# property's embedded `type` so we don't need the TasksSchema here.
198+
if location_property_name:
199+
loc_prop = props.get(location_property_name)
200+
if loc_prop:
201+
ptype = loc_prop.get("type")
202+
if ptype == "select":
203+
val = _select(loc_prop)
204+
elif ptype == "status":
205+
val = _status(loc_prop)
206+
elif ptype == "multi_select":
207+
val = _multi_select(loc_prop) # list
208+
else:
209+
val = None
210+
if val:
211+
result["location"] = val
189212
# Recurrence (Recur Unit select + Recur Interval number)
190213
recur_unit = _select(props.get("Recur Unit", {}))
191214
recur_interval = _number(props.get("Recur Interval", {}))

src/ultimate_brain_mcp/server.py

Lines changed: 39 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -453,7 +453,8 @@ async def search_tasks(
453453
pages = await app.client.query_all(
454454
app.config.tasks_ds_id, filter=query_filter, sorts=sorts
455455
)
456-
return [format_task(p) for p in pages[:limit]]
456+
loc_name = app.tasks_schema.location_property_name
457+
return [format_task(p, location_property_name=loc_name) for p in pages[:limit]]
457458
except NotionAPIError as e:
458459
return _handle_api_error(e)
459460

@@ -475,7 +476,8 @@ async def get_my_day(
475476
}
476477
try:
477478
pages = await app.client.query_all(app.config.tasks_ds_id, filter=query_filter)
478-
tasks = [format_task(p) for p in pages]
479+
loc_name = app.tasks_schema.location_property_name
480+
tasks = [format_task(p, location_property_name=loc_name) for p in pages]
479481
priority_order = {"High": 0, "Medium": 1, "Low": 2, None: 3}
480482
tasks.sort(key=lambda t: priority_order.get(t.get("priority"), 3))
481483
return tasks
@@ -501,7 +503,8 @@ async def get_inbox_tasks(
501503
}
502504
try:
503505
pages = await app.client.query_all(app.config.tasks_ds_id, filter=query_filter)
504-
return [format_task(p) for p in pages]
506+
loc_name = app.tasks_schema.location_property_name
507+
return [format_task(p, location_property_name=loc_name) for p in pages]
505508
except NotionAPIError as e:
506509
return _handle_api_error(e)
507510

@@ -601,7 +604,9 @@ async def create_task(
601604

602605
try:
603606
page = await app.client.create_page(app.config.tasks_ds_id, props, children=children)
604-
result = format_task(page)
607+
result = format_task(
608+
page, location_property_name=app.tasks_schema.location_property_name
609+
)
605610
if location_warning:
606611
result["_warning"] = location_warning
607612
return result
@@ -630,6 +635,10 @@ async def update_task(
630635
project_id: Annotated[str | None, Field(description="New project page ID.")] = None,
631636
labels: Annotated[list[str] | None, Field(description="New labels (replaces existing).")] = None,
632637
my_day: Annotated[bool | None, Field(description="Set My Day flag.")] = None,
638+
parent_task_id: Annotated[
639+
str | None,
640+
Field(description="New parent task page ID (for sub-tasks)."),
641+
] = None,
633642
tag_ids: Annotated[
634643
list[str] | None,
635644
Field(description=(
@@ -671,6 +680,8 @@ async def update_task(
671680
props["Labels"] = _prop_multi_select(labels)
672681
if my_day is not None:
673682
props["My Day"] = _prop_checkbox(my_day)
683+
if parent_task_id is not None:
684+
props["Parent Task"] = _prop_relation([parent_task_id])
674685
if tag_ids is not None:
675686
props["Tag"] = _prop_relation(tag_ids)
676687
if location is not None:
@@ -685,7 +696,9 @@ async def update_task(
685696

686697
try:
687698
page = await app.client.update_page(task_id, props)
688-
result = format_task(page)
699+
result = format_task(
700+
page, location_property_name=app.tasks_schema.location_property_name
701+
)
689702
if location_warning:
690703
result["_warning"] = location_warning
691704
return result
@@ -704,9 +717,10 @@ async def complete_task(
704717
Handles recurring tasks: resets status to To Do and advances due date by the recurrence interval.
705718
Use search_tasks to find task IDs."""
706719
app = _ctx(ctx)
720+
loc_name = app.tasks_schema.location_property_name
707721
try:
708722
page = await app.client.get_page(task_id)
709-
task = format_task(page)
723+
task = format_task(page, location_property_name=loc_name)
710724

711725
# Check for recurrence
712726
recurrence = task.get("recurrence", "")
@@ -719,7 +733,7 @@ async def complete_task(
719733
props["Due"] = _prop_date(new_due)
720734
props["My Day"] = _prop_checkbox(False)
721735
page = await app.client.update_page(task_id, props)
722-
result = format_task(page)
736+
result = format_task(page, location_property_name=loc_name)
723737
result["_note"] = f"Recurring task reset. Next due: {new_due or 'unchanged'}"
724738
return result
725739
else:
@@ -730,7 +744,7 @@ async def complete_task(
730744
"My Day": _prop_checkbox(False),
731745
}
732746
page = await app.client.update_page(task_id, props)
733-
return format_task(page)
747+
return format_task(page, location_property_name=loc_name)
734748
except NotionAPIError as e:
735749
return _handle_api_error(e, "Use search_tasks to find valid task IDs.")
736750

@@ -890,7 +904,8 @@ async def get_project_detail(
890904
)
891905

892906
project = format_project(project_page)
893-
tasks = [format_task(t) for t in task_pages]
907+
loc_name = app.tasks_schema.location_property_name
908+
tasks = [format_task(t, location_property_name=loc_name) for t in task_pages]
894909
notes = [format_note(n) for n in note_pages[:10]]
895910

896911
# Task breakdown by status
@@ -1539,7 +1554,8 @@ async def daily_summary(
15391554
my_day_fut, overdue_fut, inbox_fut, projects_fut, goals_fut
15401555
)
15411556

1542-
my_day_tasks = [format_task(p) for p in my_day]
1557+
loc_name = app.tasks_schema.location_property_name
1558+
my_day_tasks = [format_task(p, location_property_name=loc_name) for p in my_day]
15431559
priority_order = {"High": 0, "Medium": 1, "Low": 2, None: 3}
15441560
my_day_tasks.sort(key=lambda t: priority_order.get(t.get("priority"), 3))
15451561

@@ -1551,7 +1567,7 @@ async def daily_summary(
15511567
},
15521568
"overdue": {
15531569
"count": len(overdue),
1554-
"tasks": [format_task(p) for p in overdue],
1570+
"tasks": [format_task(p, location_property_name=loc_name) for p in overdue],
15551571
},
15561572
"inbox_count": len(inbox),
15571573
"active_projects_count": len(projects),
@@ -1748,12 +1764,19 @@ async def daily_review_snapshot(
17481764
formatted = format_tag(t)
17491765
tag_lookup[formatted["id"]] = {"name": formatted.get("name", "")}
17501766

1767+
loc_name = app.tasks_schema.location_property_name
1768+
17511769
def _fmt_bucket(pages: list[dict], cap: int) -> tuple[list[dict], bool]:
17521770
truncated = len(pages) > cap
17531771
sliced = pages[:cap]
17541772
return (
17551773
[
1756-
format_task(p, project_lookup=project_lookup, tag_lookup=tag_lookup)
1774+
format_task(
1775+
p,
1776+
project_lookup=project_lookup,
1777+
tag_lookup=tag_lookup,
1778+
location_property_name=loc_name,
1779+
)
17571780
for p in sliced
17581781
],
17591782
truncated,
@@ -1928,7 +1951,10 @@ async def _apply_one(idx: int, update: BulkTaskUpdate) -> dict:
19281951
row: dict = {
19291952
"task_id": update.task_id,
19301953
"ok": True,
1931-
"task": format_task(page),
1954+
"task": format_task(
1955+
page,
1956+
location_property_name=app.tasks_schema.location_property_name,
1957+
),
19321958
}
19331959
if warnings:
19341960
row["_warnings"] = warnings

tests/test_format_task_lookups.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,3 +107,73 @@ def test_format_task_no_relations_no_resolved_fields_even_with_lookups():
107107
assert "project_name" not in task
108108
assert "tag_ids" not in task
109109
assert "area_tag_names" not in task
110+
111+
112+
def _with_location(page: dict, prop_name: str, prop: dict) -> dict:
113+
"""Attach a Location-style property to a task page under *prop_name*."""
114+
page["properties"][prop_name] = prop
115+
return page
116+
117+
118+
def test_format_task_location_select():
119+
"""A select-typed Location property is surfaced as a string."""
120+
page = _with_location(
121+
_task_page(), "Location", {"type": "select", "select": {"name": "Home"}}
122+
)
123+
124+
task = format_task(page, location_property_name="Location")
125+
126+
assert task["location"] == "Home"
127+
128+
129+
def test_format_task_location_status():
130+
"""A status-typed Location property is surfaced as a string."""
131+
page = _with_location(
132+
_task_page(), "Location", {"type": "status", "status": {"name": "Office"}}
133+
)
134+
135+
task = format_task(page, location_property_name="Location")
136+
137+
assert task["location"] == "Office"
138+
139+
140+
def test_format_task_location_multi_select():
141+
"""A multi_select-typed Location property is surfaced as a list."""
142+
page = _with_location(
143+
_task_page(),
144+
"Where",
145+
{"type": "multi_select", "multi_select": [{"name": "Home"}, {"name": "Errands"}]},
146+
)
147+
148+
task = format_task(page, location_property_name="Where")
149+
150+
assert task["location"] == ["Home", "Errands"]
151+
152+
153+
def test_format_task_location_omitted_without_name():
154+
"""Without location_property_name, location is never added (legacy shape)."""
155+
page = _with_location(
156+
_task_page(), "Location", {"type": "select", "select": {"name": "Home"}}
157+
)
158+
159+
task = format_task(page)
160+
161+
assert "location" not in task
162+
163+
164+
def test_format_task_location_absent_when_property_missing():
165+
"""A name that isn't present on the page simply yields no location field."""
166+
task = format_task(_task_page(), location_property_name="Location")
167+
168+
assert "location" not in task
169+
170+
171+
def test_format_task_location_absent_when_empty():
172+
"""An empty Location value yields no location field."""
173+
page = _with_location(
174+
_task_page(), "Location", {"type": "select", "select": None}
175+
)
176+
177+
task = format_task(page, location_property_name="Location")
178+
179+
assert "location" not in task

0 commit comments

Comments
 (0)