Fix ty diagnostics in task tests, scope ty exclusion to client-task files

This commit is contained in:
Jeremiah Lowin 2026-07-21 23:10:00 -04:00
commit bc22e517fd
No known key found for this signature in database
18 changed files with 84 additions and 24 deletions

View file

@ -45,9 +45,7 @@ __all__ = [
#: the tasks extension in for the request.
MISSING_REQUIRED_CLIENT_CAPABILITY = -32003
TaskStatus = Literal[
"working", "input_required", "completed", "failed", "cancelled"
]
TaskStatus = Literal["working", "input_required", "completed", "failed", "cancelled"]
class _TaskFields(BaseModel):
@ -69,7 +67,9 @@ class _TaskFields(BaseModel):
created_at: str = Field(serialization_alias="createdAt")
last_updated_at: str = Field(serialization_alias="lastUpdatedAt")
ttl_ms: float | None = Field(serialization_alias="ttlMs")
status_message: str | None = Field(default=None, serialization_alias="statusMessage")
status_message: str | None = Field(
default=None, serialization_alias="statusMessage"
)
poll_interval_ms: float | None = Field(
default=None, serialization_alias="pollIntervalMs"
)

View file

@ -155,10 +155,9 @@ exclude = [
"examples/providers/sqlite", # needs aiosqlite
"examples/memory.py", # needs asyncpg, numpy, pydantic_ai, pgvector
"examples/get_file.py", # needs aiohttp
# The moved task tests pass at runtime but carry ty diagnostics (mostly
# None-narrowing on optional result fields); a follow-up commit fixes them
# and removes this exclusion.
"tests/tasks",
# Skipped pending client task support; rewritten in the client-task follow-up.
"tests/tasks/client/test_task_context_validation.py",
"tests/tasks/client/test_task_result_caching.py",
]
[tool.ty.environment]

View file

@ -99,6 +99,7 @@ async def test_concurrent_background_tasks_with_context():
assert len(finals) == 4
for final in finals:
assert final.status == "completed"
assert final.result is not None
assert final.result["structuredContent"]["result"].startswith("bg:")
@ -134,6 +135,7 @@ async def test_concurrent_background_tasks_with_progress():
assert len(finals) == 4
for final in finals:
assert final.status == "completed"
assert final.result is not None
assert final.result["structuredContent"]["result"].startswith("bg:")
@ -202,6 +204,7 @@ async def test_sync_context_functions_work_in_background_without_deps():
final = await wait_for_task(mcp, created.task_id)
assert final.status == "completed"
assert final.result is not None
assert final.result["structuredContent"] == {"has_headers": "False"}
@ -225,4 +228,5 @@ async def test_sync_context_functions_work_in_background_with_context():
final = await wait_for_task(mcp, created.task_id)
assert final.status == "completed"
assert final.result is not None
assert final.result["structuredContent"]["is_background"] == "True"

View file

@ -356,6 +356,7 @@ class TestBackgroundTaskIntegration:
final = await wait_for_task(mcp, created.task_id)
assert final.status == "completed"
assert final.result is not None
assert final.result["structuredContent"] == {"result": "done"}
async def test_context_wiring_in_background_task(self):
@ -382,6 +383,7 @@ class TestBackgroundTaskIntegration:
final = await wait_for_task(mcp, created.task_id)
assert final.status == "completed"
assert final.result is not None
assert final.result["structuredContent"] == {
"task_id_set": True,
"is_background": True,
@ -406,6 +408,7 @@ class TestBackgroundTaskIntegration:
parked = await wait_for_task(
mcp, created.task_id, target_states=frozenset({"input_required"})
)
assert parked.input_requests is not None
key = next(iter(parked.input_requests))
await update_task(
mcp,
@ -415,6 +418,7 @@ class TestBackgroundTaskIntegration:
final = await wait_for_task(mcp, created.task_id)
assert final.status == "completed"
assert final.result is not None
assert final.result["structuredContent"] == {"result": "Hello, Bob!"}
async def test_elicit_decline_flow(self):
@ -436,11 +440,13 @@ class TestBackgroundTaskIntegration:
parked = await wait_for_task(
mcp, created.task_id, target_states=frozenset({"input_required"})
)
assert parked.input_requests is not None
key = next(iter(parked.input_requests))
await update_task(mcp, created.task_id, {key: {"action": "decline"}})
final = await wait_for_task(mcp, created.task_id)
assert final.status == "completed"
assert final.result is not None
assert final.result["structuredContent"] == {"result": "User declined"}
async def test_elicit_with_pydantic_model(self):
@ -466,6 +472,7 @@ class TestBackgroundTaskIntegration:
parked = await wait_for_task(
mcp, created.task_id, target_states=frozenset({"input_required"})
)
assert parked.input_requests is not None
key = next(iter(parked.input_requests))
await update_task(
mcp,
@ -475,6 +482,7 @@ class TestBackgroundTaskIntegration:
final = await wait_for_task(mcp, created.task_id)
assert final.status == "completed"
assert final.result is not None
assert final.result["structuredContent"] == {"result": "Alice is 30"}
@ -511,6 +519,7 @@ class TestAccessTokenInBackgroundTasks:
final = await wait_for_task(mcp, created.task_id)
assert final.status == "completed"
assert final.result is not None
assert final.result["structuredContent"] == {
"result": "roundtrip-jwt|test-client"
}
@ -530,6 +539,7 @@ class TestAccessTokenInBackgroundTasks:
final = await wait_for_task(mcp, created.task_id)
assert final.status == "completed"
assert final.result is not None
assert final.result["structuredContent"] == {"result": "no-token"}

View file

@ -92,6 +92,7 @@ async def test_custom_tool_background_execution(custom_tool_server):
final = await run_task(custom_tool_server, "custom_tool", {})
assert final.status == "completed"
assert final.result is not None
assert "Custom tool executed" in final.result["content"][0]["text"]
@ -101,6 +102,7 @@ async def test_custom_tool_with_arguments(custom_tool_server):
final = await run_task(custom_tool_server, "custom_logic", {"duration": 1})
assert final.status == "completed"
assert final.result is not None
assert "Completed after 1 units" in final.result["content"][0]["text"]

View file

@ -12,6 +12,7 @@ from __future__ import annotations
import asyncio
from contextlib import AsyncExitStack
from types import SimpleNamespace
from typing import cast
import pytest
from fastmcp_tasks.models import (
@ -19,6 +20,7 @@ from fastmcp_tasks.models import (
CreateTaskResult,
)
from mcp.server.context import ServerRequestContext
from mcp.server.session import ServerSession
from mcp.shared.exceptions import MCPError
from fastmcp import FastMCP
@ -140,6 +142,7 @@ async def test_required_tool_tasks_when_opted_in():
created = await submit_task(mcp, "must_task", {"n": 10})
final = await wait_for_task(mcp, created.task_id)
assert final.status == "completed"
assert final.result is not None
assert final.result["structuredContent"]["result"] == 11
@ -197,6 +200,7 @@ async def test_task_arguments_are_coerced_like_sync_path():
async with running_task_server(mcp):
# "6" coerces to int 6 exactly as the synchronous path would.
final = await run_task(mcp, "square", {"n": "6"})
assert final.result is not None
assert final.result["structuredContent"]["result"] == 36
@ -308,7 +312,7 @@ async def test_legacy_era_opt_in_is_ignored():
mcp = _tasks_server()
async with running_task_server(mcp):
srctx = ServerRequestContext(
session=SimpleNamespace(),
session=cast(ServerSession, SimpleNamespace()),
lifespan_context={},
protocol_version="2025-06-18",
method="tools/call",
@ -324,7 +328,7 @@ async def test_legacy_era_required_tool_raises_missing_capability():
mcp = _tasks_server()
async with running_task_server(mcp):
srctx = ServerRequestContext(
session=SimpleNamespace(),
session=cast(ServerSession, SimpleNamespace()),
lifespan_context={},
protocol_version="2025-06-18",
method="tools/call",

View file

@ -48,6 +48,7 @@ async def test_progress_in_background_task():
created = await submit_task(mcp, "test_task", {})
final = await wait_for_task(mcp, created.task_id)
assert final.status == "completed"
assert final.result is not None
assert final.result["structuredContent"] == {"result": "done"}
@ -97,6 +98,7 @@ async def test_progress_status_message_in_background_task():
release.set()
final = await wait_for_task(mcp, created.task_id)
assert final.status == "completed"
assert final.result is not None
assert final.result["structuredContent"] == {"result": "done"}

View file

@ -130,6 +130,7 @@ async def test_task_with_custom_tool_name():
async with running_task_server(mcp):
final = await run_task(mcp, "custom-tool-name")
assert final.status == "completed"
assert final.result is not None
assert final.result["structuredContent"] == {
"result": "result from custom-named tool"
}

View file

@ -48,6 +48,7 @@ async def test_snapshot_restored_before_user_code_runs():
final = await wait_for_task(mcp, created.task_id)
assert final.status == "completed"
assert final.result is not None
assert final.result["structuredContent"] == {"result": True}
@ -75,6 +76,7 @@ async def test_get_access_token_in_bg_task_without_context_dep():
final = await wait_for_task(mcp, created.task_id)
assert final.status == "completed"
assert final.result is not None
assert final.result["structuredContent"] == {"result": "jwt-3897"}
@ -99,6 +101,7 @@ async def test_restore_failure_is_nonfatal():
final = await wait_for_task(mcp, created.task_id)
assert final.status == "completed"
assert final.result is not None
assert final.result["structuredContent"] == {"result": False}

View file

@ -180,6 +180,7 @@ class TestToolExecutionMetadata:
return "ok"
tool = await mcp.get_tool("my_tool")
assert tool is not None
execution = tool.to_mcp_tool().execution
assert isinstance(execution, ToolExecution)
assert execution.task_support == "optional"
@ -194,6 +195,7 @@ class TestToolExecutionMetadata:
return "ok"
tool = await mcp.get_tool("my_tool")
assert tool is not None
execution = tool.to_mcp_tool().execution
assert isinstance(execution, ToolExecution)
assert execution.task_support == "required"
@ -208,6 +210,7 @@ class TestToolExecutionMetadata:
return "ok"
tool = await mcp.get_tool("my_tool")
assert tool is not None
assert tool.to_mcp_tool().execution is None

View file

@ -73,6 +73,7 @@ async def test_background_tool_receives_docket_dependency(dependency_server):
final = await run_task(dependency_server, "tool_with_docket_dependency", {})
assert final.status == "completed"
assert final.result is not None
assert final.result["structuredContent"] == {"result": "Docket: True"}
assert len(dependency_server._injected_values) == 1
dep_type, dep_value = dependency_server._injected_values[0]
@ -88,6 +89,7 @@ async def test_background_tool_receives_server_dependency(dependency_server):
final = await run_task(dependency_server, "tool_with_server_dependency", {})
assert final.status == "completed"
assert final.result is not None
assert final.result["structuredContent"] == {
"result": f"Server: {dependency_server.name}"
}
@ -107,6 +109,7 @@ async def test_background_tool_receives_custom_depends(dependency_server):
)
assert final.status == "completed"
assert final.result is not None
assert final.result["structuredContent"] == {"result": 50} # 5 * 10
assert len(dependency_server._injected_values) == 1
dep_type, dep_value = dependency_server._injected_values[0]
@ -124,6 +127,7 @@ async def test_background_tool_with_multiple_dependencies(dependency_server):
)
assert final.status == "completed"
assert final.result is not None
assert final.result["structuredContent"] == {
"result": f"test on {dependency_server.name}"
}
@ -180,6 +184,7 @@ async def test_dependency_context_managers_cleaned_up_in_background():
final = await run_task(mcp, "use_connection", {"name": "test"})
assert final.status == "completed"
assert final.result is not None
assert final.result["structuredContent"] == {"result": "Used: connection"}
assert cleanup_called == ["enter", "exit"]

View file

@ -62,6 +62,7 @@ async def _drive(server: FastMCP, name: str, answers: list[dict[str, Any]]) -> s
await update_task(server, created.task_id, {key: answer})
final = await wait_for_task(server, created.task_id)
assert final.status == "completed", final.error
assert final.result is not None
return final.result["content"][0]["text"]
@ -214,4 +215,5 @@ async def test_unanswered_input_times_out_to_cancel(monkeypatch):
# Never answer; the worker's bounded wait resolves to cancel.
final = await wait_for_task(mcp, created.task_id, timeout=10.0)
assert final.status == "completed"
assert final.result is not None
assert final.result["content"][0]["text"] == "Cancelled as expected"

View file

@ -53,7 +53,9 @@ async def test_tasks_get_returns_status_and_inlined_result():
final = await wait_for_task(mcp, created.task_id)
assert final.status == "completed"
assert final.result is not None
assert final.result["structuredContent"] == {"result": 42}
assert final.result is not None
assert final.result["isError"] is False

View file

@ -20,6 +20,7 @@ Two architectural notes vs. SEP-1686:
from __future__ import annotations
import asyncio
from typing import cast
import mcp_types as mt
import pytest
@ -31,7 +32,7 @@ from mcp_types import ToolExecution
from fastmcp import Context, FastMCP
from fastmcp.server.dependencies import CurrentFastMCP
from fastmcp.server.middleware import CallNext, Middleware, MiddlewareContext
from fastmcp.server.providers.proxy import ProxyTool
from fastmcp.server.providers.proxy import ClientFactoryT, ProxyTool
from fastmcp.tools.base import ToolResult
from fastmcp.utilities.tasks import TaskConfig
from fastmcp_tasks import TasksExtension
@ -90,6 +91,7 @@ class TestMountedToolTasks:
assert created.status == "working"
final = await wait_for_task(parent_server, created.task_id)
assert final.status == "completed"
assert final.result is not None
assert final.result["structuredContent"]["result"] == 72
async def test_mounted_and_parent_tasks_both_work(self, parent_server):
@ -102,7 +104,9 @@ class TestMountedToolTasks:
)
parent_final = await wait_for_task(parent_server, parent_created.task_id)
child_final = await wait_for_task(parent_server, child_created.task_id)
assert parent_final.result is not None
assert parent_final.result["structuredContent"]["result"] == 50
assert child_final.result is not None
assert child_final.result["structuredContent"]["result"] == 6
async def test_sync_only_mounted_tool_runs_synchronously(self, parent_server):
@ -129,6 +133,7 @@ class TestMountedToolTasksNoPrefix:
parent,
(await submit_task(parent, "multiply", {"a": 5, "b": 6})).task_id,
)
assert final.result is not None
assert final.result["structuredContent"]["result"] == 30
@ -137,7 +142,7 @@ class TestMountedTaskDependencies:
child = FastMCP("dep-child")
@child.tool(task=True)
async def tool_with_docket(docket: CurrentDocket = CurrentDocket()) -> str: # type: ignore[assignment,valid-type]
async def tool_with_docket(docket: Docket = CurrentDocket()) -> str:
return f"docket available: {docket is not None}"
parent = FastMCP("dep-parent")
@ -149,6 +154,7 @@ class TestMountedTaskDependencies:
parent,
(await submit_task(parent, "child_tool_with_docket", {})).task_id,
)
assert final.result is not None
assert "docket available: True" in final.result["content"][0]["text"]
@ -157,7 +163,7 @@ class TestMountedTaskServerContext:
child = FastMCP("child")
@child.tool(task=True)
async def whoami(server: CurrentFastMCP = CurrentFastMCP()) -> str: # type: ignore[assignment,valid-type]
async def whoami(server: FastMCP = CurrentFastMCP()) -> str:
return f"server name: {server.name}"
parent = FastMCP("parent")
@ -168,6 +174,7 @@ class TestMountedTaskServerContext:
final = await wait_for_task(
parent, (await submit_task(parent, "child_whoami", {})).task_id
)
assert final.result is not None
assert "server name: child" in final.result["content"][0]["text"]
async def test_context_fastmcp_resolves_to_child_server(self):
@ -185,6 +192,7 @@ class TestMountedTaskServerContext:
final = await wait_for_task(
parent, (await submit_task(parent, "child_whoami_ctx", {})).task_id
)
assert final.result is not None
assert "context server: child" in final.result["content"][0]["text"]
@ -217,7 +225,9 @@ class TestMultipleMounts:
await submit_task(parent, "math2_subtract", {"a": 10, "b": 5})
).task_id,
)
assert r1.result is not None
assert r1.result["structuredContent"]["result"] == 15
assert r2.result is not None
assert r2.result["structuredContent"]["result"] == 5
async def test_same_function_names_do_not_collide(self):
@ -246,7 +256,9 @@ class TestMultipleMounts:
parent,
(await submit_task(parent, "c2_process", {"value": 10})).task_id,
)
assert r1.result is not None
assert r1.result["structuredContent"]["result"] == 20
assert r2.result is not None
assert r2.result["structuredContent"]["result"] == 30
async def test_nested_mount_prefix_accumulation(self):
@ -267,6 +279,7 @@ class TestMultipleMounts:
parent,
(await submit_task(parent, "child_gc_deep_tool", {})).task_id,
)
assert final.result is not None
assert final.result["structuredContent"]["result"] == "deep"
@ -286,6 +299,8 @@ class TestMountedTaskMetadata:
child_mcp = child_tool.to_mcp_tool(name=child_tool.name)
parent_mcp = parent_tool.to_mcp_tool(name=parent_tool.name)
assert child_mcp.execution is not None
assert parent_mcp.execution is not None
assert child_mcp.execution.task_support == "optional"
assert parent_mcp.execution.task_support == "optional"
@ -296,7 +311,7 @@ class TestMountedTaskMetadata:
input_schema={"type": "object", "properties": {}},
execution=ToolExecution(task_support="optional"),
)
proxy = ProxyTool.from_mcp_tool(lambda: None, mcp_tool) # type: ignore[arg-type]
proxy = ProxyTool.from_mcp_tool(cast(ClientFactoryT, lambda: None), mcp_tool)
result = proxy.to_mcp_tool(name=proxy.name)
assert result.execution is not None
assert result.execution.task_support == "optional"
@ -339,6 +354,7 @@ class TestMountedTaskConfigModes:
await submit_task(parent_with_modes, "child_optional_tool", {})
).task_id,
)
assert final.result is not None
assert final.result["structuredContent"]["result"] == "optional result"
async def test_required_mode_with_task_through_mount(self, parent_with_modes):
@ -349,6 +365,7 @@ class TestMountedTaskConfigModes:
await submit_task(parent_with_modes, "child_required_tool", {})
).task_id,
)
assert final.result is not None
assert final.result["structuredContent"]["result"] == "required result"
async def test_required_mode_without_task_through_mount(self, parent_with_modes):
@ -417,6 +434,7 @@ class TestMiddlewareWithMountedTasks:
async with running_task_server(parent):
created = await submit_task(parent, "c_gc_compute", {"x": 5})
final = await wait_for_task(parent, created.task_id)
assert final.result is not None
assert final.result["structuredContent"]["result"] == 10
assert calls == ["parent:before", "parent:after", "grandchild:tool"]

View file

@ -47,7 +47,9 @@ async def test_same_client_can_access_all_its_tasks(task_server: FastMCP):
second = await run_task(
task_server, "secret_tool", {"data": "second"}, access_token=token
)
assert first.result is not None
assert "first" in first.result["content"][0]["text"]
assert second.result is not None
assert "second" in second.result["content"][0]["text"]
@ -55,6 +57,7 @@ async def test_unauthenticated_client_can_access_its_tasks(task_server: FastMCP)
"""An anonymous caller can resolve tasks in the anonymous keyspace."""
async with running_task_server(task_server):
final = await run_task(task_server, "secret_tool", {"data": "hello"})
assert final.result is not None
assert "hello" in final.result["content"][0]["text"]

View file

@ -84,6 +84,7 @@ async def test_task_tool_coerces_model_arguments():
final = await run_task(mcp, "inspect_items", arguments)
assert sync_result.structured_content == expected
assert final.result is not None
assert final.result["structuredContent"] == expected
@ -99,6 +100,7 @@ async def test_task_arguments_are_coerced_like_sync_path():
async with running_task_server(mcp):
final = await run_task(mcp, "square", {"n": "1"})
assert final.status == "completed"
assert final.result is not None
assert final.result["structuredContent"] == {"result": 1}
@ -137,6 +139,7 @@ async def test_valid_argument_submits_under_strict_validation():
async with running_task_server(mcp):
final = await run_task(mcp, "square", {"n": 4})
assert final.status == "completed"
assert final.result is not None
assert final.result["structuredContent"] == {"result": 16}
@ -202,6 +205,7 @@ async def test_tool_task_executes_in_background():
finish.set()
final = await wait_for_task(mcp, created.task_id)
assert final.status == "completed"
assert final.result is not None
assert final.result["structuredContent"] == {"result": "completed"}

View file

@ -22,6 +22,7 @@ from fastmcp_tasks.models import (
CancelTaskResult,
CreateTaskResult,
GetTaskResult,
TaskStatus,
UpdateTaskResult,
)
from jsonschema import Draft202012Validator
@ -72,10 +73,10 @@ def test_create_task_result_matches_schema():
("cancelled", {}),
],
)
def test_get_task_result_matches_schema(status: str, payload: dict[str, Any]):
def test_get_task_result_matches_schema(status: TaskStatus, payload: dict[str, Any]):
result = GetTaskResult(
task_id="t1",
status=status, # type: ignore[arg-type]
status=status,
created_at=_ISO,
last_updated_at=_ISO,
ttl_ms=900000,
@ -111,9 +112,7 @@ def test_null_ttl_is_permitted_by_schema():
)
dumped = result.model_dump(by_alias=True, mode="json", exclude_none=False)
# Drop the other None optionals the runner would also drop, keeping ttlMs=null.
dumped = {
k: v for k, v in dumped.items() if v is not None or k == "ttlMs"
}
dumped = {k: v for k, v in dumped.items() if v is not None or k == "ttlMs"}
_validate("CreateTaskResult", dumped)

View file

@ -25,7 +25,7 @@ from __future__ import annotations
import asyncio
import contextlib
from types import SimpleNamespace
from typing import Any
from typing import Any, cast
from fastmcp_tasks.handlers import tasks_cancel, tasks_get, tasks_update
from fastmcp_tasks.models import (
@ -37,6 +37,7 @@ from fastmcp_tasks.models import (
from mcp.server.auth.middleware.auth_context import auth_context_var
from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser
from mcp.server.context import ServerRequestContext
from mcp.server.session import ServerSession
from mcp_types import CLIENT_CAPABILITIES_META_KEY
from fastmcp.server.auth import AccessToken
@ -91,7 +92,7 @@ def _opted_in_request(
"_meta": opt_in_meta(settings),
}
srctx = ServerRequestContext(
session=SimpleNamespace(),
session=cast(ServerSession, SimpleNamespace()),
lifespan_context={},
protocol_version="2026-07-28",
method="tools/call",
@ -202,9 +203,7 @@ async def run_task(
timeout: float = 5.0,
) -> GetTaskResult:
"""Submit a task and wait for it to reach a terminal state."""
created = await submit_task(
server, name, arguments, access_token=access_token
)
created = await submit_task(server, name, arguments, access_token=access_token)
return await wait_for_task(
server, created.task_id, access_token=access_token, timeout=timeout
)