Skip to content

Commit 0c311d6

Browse files
committed
Add lastUpdatedAt field and related-task metadata for spec conformance
This addresses two critical spec compliance gaps: 1. Add `lastUpdatedAt` field to Task model - Required by spec: ISO 8601 timestamp updated on every status change - Added to Task model in types.py - Initialized alongside createdAt in create_task_state() - Updated in InMemoryTaskStore.update_task() on any change - Included in all Task responses and notifications 2. Add related-task metadata to tasks/result response - Per spec: tasks/result MUST include _meta with io.modelcontextprotocol/related-task containing the taskId - Required because result structure doesn't contain task ID - Merges with any existing _meta from stored result
1 parent c7fc284 commit 0c311d6

File tree

15 files changed

+65
-15
lines changed

15 files changed

+65
-15
lines changed

examples/servers/simple-task-interactive/mcp_simple_task_interactive/server.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,7 @@ async def handle_get_task(request: types.GetTaskRequest) -> types.GetTaskResult:
187187
status=task.status,
188188
statusMessage=task.statusMessage,
189189
createdAt=task.createdAt,
190+
lastUpdatedAt=task.lastUpdatedAt,
190191
ttl=task.ttl,
191192
pollInterval=task.pollInterval,
192193
)

examples/servers/simple-task/mcp_simple_task/server.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ async def handle_get_task(request: types.GetTaskRequest) -> types.GetTaskResult:
9090
status=task.status,
9191
statusMessage=task.statusMessage,
9292
createdAt=task.createdAt,
93+
lastUpdatedAt=task.lastUpdatedAt,
9394
ttl=task.ttl,
9495
pollInterval=task.pollInterval,
9596
)

src/mcp/shared/experimental/tasks/context.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,7 @@ async def _send_notification(self) -> None:
132132
status=self._task.status,
133133
statusMessage=self._task.statusMessage,
134134
createdAt=self._task.createdAt,
135+
lastUpdatedAt=self._task.lastUpdatedAt,
135136
ttl=self._task.ttl,
136137
pollInterval=self._task.pollInterval,
137138
)

src/mcp/shared/experimental/tasks/helpers.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,10 +54,12 @@ def create_task_state(
5454
Returns:
5555
A new Task in "working" status
5656
"""
57+
now = datetime.now(timezone.utc)
5758
return Task(
5859
taskId=task_id or generate_task_id(),
5960
status="working",
60-
createdAt=datetime.now(timezone.utc),
61+
createdAt=now,
62+
lastUpdatedAt=now,
6163
ttl=metadata.ttl,
6264
pollInterval=500, # Default 500ms poll interval
6365
)

src/mcp/shared/experimental/tasks/in_memory_task_store.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,9 @@ async def update_task(
122122
if status_message is not None:
123123
stored.task.statusMessage = status_message
124124

125+
# Update lastUpdatedAt on any change
126+
stored.task.lastUpdatedAt = datetime.now(timezone.utc)
127+
125128
# If task is now terminal and has TTL, reset expiry timer
126129
if status is not None and is_terminal(status) and stored.task.ttl is not None:
127130
stored.expires_at = self._calculate_expiry(stored.task.ttl)

src/mcp/shared/experimental/tasks/result_handler.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -128,10 +128,17 @@ async def handle(
128128
result = await self._store.get_result(task_id)
129129
# GetTaskPayloadResult is a Result with extra="allow"
130130
# The stored result contains the actual payload data
131+
# Per spec: tasks/result MUST include _meta.io.modelcontextprotocol/related-task
132+
# with taskId, as the result structure itself does not contain the task ID
133+
related_task_meta: dict[str, Any] = {"io.modelcontextprotocol/related-task": {"taskId": task_id}}
131134
if result is not None:
132-
# Copy result fields into GetTaskPayloadResult
133-
return GetTaskPayloadResult.model_validate(result.model_dump(by_alias=True))
134-
return GetTaskPayloadResult()
135+
# Copy result fields and add required metadata
136+
result_data = result.model_dump(by_alias=True)
137+
# Merge with existing _meta if present
138+
existing_meta: dict[str, Any] = result_data.get("_meta") or {}
139+
result_data["_meta"] = {**existing_meta, **related_task_meta}
140+
return GetTaskPayloadResult.model_validate(result_data)
141+
return GetTaskPayloadResult.model_validate({"_meta": related_task_meta})
135142

136143
# Wait for task update (status change or new messages)
137144
await self._wait_for_task_update(task_id)

src/mcp/types.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -492,6 +492,9 @@ class Task(BaseModel):
492492
createdAt: datetime # Pydantic will enforce ISO 8601 and re-serialize as a string later
493493
"""ISO 8601 timestamp when the task was created."""
494494

495+
lastUpdatedAt: datetime
496+
"""ISO 8601 timestamp when the task was last updated."""
497+
495498
ttl: Annotated[int, Field(strict=True)] | None
496499
"""Actual retention duration from creation in milliseconds, null for unlimited."""
497500

tests/experimental/tasks/client/test_handlers.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ async def get_task_handler(
7676
status=task.status,
7777
statusMessage=task.statusMessage,
7878
createdAt=task.createdAt,
79+
lastUpdatedAt=task.lastUpdatedAt,
7980
ttl=task.ttl,
8081
pollInterval=task.pollInterval,
8182
)
@@ -327,6 +328,7 @@ async def cancel_task_handler(
327328
taskId=updated.taskId,
328329
status=updated.status,
329330
createdAt=updated.createdAt,
331+
lastUpdatedAt=updated.lastUpdatedAt,
330332
ttl=updated.ttl,
331333
)
332334

@@ -450,6 +452,7 @@ async def get_task_handler(
450452
status=task.status,
451453
statusMessage=task.statusMessage,
452454
createdAt=task.createdAt,
455+
lastUpdatedAt=task.lastUpdatedAt,
453456
ttl=task.ttl,
454457
pollInterval=task.pollInterval,
455458
)

tests/experimental/tasks/client/test_tasks.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ async def handle_get_task(request: GetTaskRequest) -> GetTaskResult:
9696
status=task.status,
9797
statusMessage=task.statusMessage,
9898
createdAt=task.createdAt,
99+
lastUpdatedAt=task.lastUpdatedAt,
99100
ttl=task.ttl,
100101
pollInterval=task.pollInterval,
101102
)
@@ -419,6 +420,7 @@ async def handle_get_task(request: GetTaskRequest) -> GetTaskResult:
419420
status=task.status,
420421
statusMessage=task.statusMessage,
421422
createdAt=task.createdAt,
423+
lastUpdatedAt=task.lastUpdatedAt,
422424
ttl=task.ttl,
423425
pollInterval=task.pollInterval,
424426
)
@@ -437,6 +439,7 @@ async def handle_cancel_task(request: CancelTaskRequest) -> CancelTaskResult:
437439
taskId=updated_task.taskId,
438440
status=updated_task.status,
439441
createdAt=updated_task.createdAt,
442+
lastUpdatedAt=updated_task.lastUpdatedAt,
440443
ttl=updated_task.ttl,
441444
)
442445

tests/experimental/tasks/server/test_elicitation_flow.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,7 @@ async def handle_get_task(request: GetTaskRequest) -> GetTaskResult:
169169
status=task.status,
170170
statusMessage=task.statusMessage,
171171
createdAt=task.createdAt,
172+
lastUpdatedAt=task.lastUpdatedAt,
172173
ttl=task.ttl,
173174
pollInterval=task.pollInterval,
174175
)

0 commit comments

Comments
 (0)