Fix task capabilities location (issue #2870)

Tasks belong in capabilities.tasks (first-class field) per SEP-1686,
not capabilities.experimental.tasks. This fixes VS Code Copilot 1.107+
integration which checks capabilities.tasks?.requests?.tools?.call.

Changes:
- Update get_task_capabilities() to return ServerTasksCapability types
- Override get_capabilities() in LowLevelServer to set tasks field
- Remove experimental_capabilities parameter usage
- Update test to verify correct location

Fixes #2870
This commit is contained in:
Jeremiah Lowin 2026-01-13 22:14:08 -05:00
commit dec3c6d449
6 changed files with 56 additions and 41 deletions

View file

@ -36,7 +36,6 @@ from fastmcp.client.auth.oauth import OAuth
from fastmcp.mcp_config import MCPConfig, infer_transport_type_from_url
from fastmcp.server.dependencies import get_http_headers
from fastmcp.server.server import FastMCP
from fastmcp.server.tasks.capabilities import get_task_capabilities
from fastmcp.utilities.logging import get_logger
from fastmcp.utilities.mcp_server_config.v1.environments.uv import UVEnvironment
@ -900,16 +899,11 @@ class FastMCPTransport(ClientTransport):
anyio.create_task_group() as tg,
_enter_server_lifespan(server=self.server),
):
# Build experimental capabilities
experimental_capabilities = get_task_capabilities()
tg.start_soon(
lambda: self.server._mcp_server.run(
server_read,
server_write,
self.server._mcp_server.create_initialization_options(
experimental_capabilities=experimental_capabilities
),
self.server._mcp_server.create_initialization_options(),
raise_exceptions=self.raise_exceptions,
)
)

View file

@ -21,7 +21,6 @@ from starlette.types import Lifespan, Receive, Scope, Send
from fastmcp.server.auth import AuthProvider
from fastmcp.server.auth.middleware import RequireAuthMiddleware
from fastmcp.server.tasks.capabilities import get_task_capabilities
from fastmcp.utilities.logging import get_logger
if TYPE_CHECKING:
@ -160,15 +159,10 @@ def create_sse_app(
# Create handler for SSE connections
async def handle_sse(scope: Scope, receive: Receive, send: Send) -> Response:
async with sse.connect_sse(scope, receive, send) as streams:
# Build experimental capabilities
experimental_capabilities = get_task_capabilities()
await server._mcp_server.run(
streams[0],
streams[1],
server._mcp_server.create_initialization_options(
experimental_capabilities=experimental_capabilities
),
server._mcp_server.create_initialization_options(),
)
return Response()

View file

@ -163,6 +163,31 @@ class LowLevelServer(_Server[LifespanResultT, RequestT]):
**kwargs,
)
def get_capabilities(
self,
notification_options: NotificationOptions,
experimental_capabilities: dict[str, dict[str, Any]],
) -> mcp.types.ServerCapabilities:
"""Override to set capabilities.tasks as a first-class field per SEP-1686.
This ensures task capabilities appear in capabilities.tasks instead of
capabilities.experimental.tasks, which is required by the MCP spec and
enables proper task detection by clients like VS Code Copilot 1.107+.
"""
from fastmcp.server.tasks.capabilities import get_task_capabilities
# Get base capabilities from SDK (pass empty dict for experimental)
# since we'll set tasks as a first-class field instead
capabilities = super().get_capabilities(
notification_options,
experimental_capabilities or {},
)
# Set tasks as a first-class field (not experimental) per SEP-1686
capabilities.tasks = get_task_capabilities()
return capabilities
async def run(
self,
read_stream: MemoryObjectReceiveStream[SessionMessage | Exception],

View file

@ -75,7 +75,6 @@ from fastmcp.server.http import (
)
from fastmcp.server.low_level import LowLevelServer
from fastmcp.server.middleware import Middleware, MiddlewareContext
from fastmcp.server.tasks.capabilities import get_task_capabilities
from fastmcp.server.tasks.config import TaskConfig
from fastmcp.server.tasks.handlers import (
handle_prompt_as_task,
@ -2508,9 +2507,6 @@ class FastMCP(Generic[LifespanResultT]):
f"Starting MCP server {self.name!r} with transport 'stdio'"
)
# Build experimental capabilities
experimental_capabilities = get_task_capabilities()
await self._mcp_server.run(
read_stream,
write_stream,
@ -2518,7 +2514,6 @@ class FastMCP(Generic[LifespanResultT]):
notification_options=NotificationOptions(
tools_changed=True
),
experimental_capabilities=experimental_capabilities,
),
)

View file

@ -1,22 +1,29 @@
"""SEP-1686 task capabilities declaration."""
from typing import Any
from mcp.types import (
ServerTasksCapability,
ServerTasksRequestsCapability,
TasksCancelCapability,
TasksListCapability,
TasksToolsCapability,
)
def get_task_capabilities() -> dict[str, Any]:
"""Return the SEP-1686 task capabilities structure.
def get_task_capabilities() -> ServerTasksCapability:
"""Return the SEP-1686 task capabilities.
This is the standard capabilities map advertised to clients,
declaring support for list, cancel, and request operations.
Returns task capabilities as a first-class ServerCapabilities field,
declaring support for list, cancel, and request operations per SEP-1686.
Note: prompts/resources are passed via extra_data since the SDK types
don't include them yet (FastMCP supports them ahead of the spec).
"""
return {
"tasks": {
"list": {},
"cancel": {},
"requests": {
"tools": {"call": {}},
"prompts": {"get": {}},
"resources": {"read": {}},
},
}
}
return ServerTasksCapability(
list=TasksListCapability(),
cancel=TasksCancelCapability(),
requests=ServerTasksRequestsCapability(
tools=TasksToolsCapability(call={}),
prompts={"get": {}},
resources={"read": {}},
),
)

View file

@ -11,7 +11,7 @@ from fastmcp.server.tasks import get_task_capabilities
async def test_capabilities_include_tasks():
"""Server capabilities always include tasks."""
"""Server capabilities always include tasks in first-class field (SEP-1686)."""
mcp = FastMCP("capability-test")
@mcp.tool()
@ -22,11 +22,11 @@ async def test_capabilities_include_tasks():
# Get server initialization result which includes capabilities
init_result = client.initialize_result
# Verify tasks capability is present
assert init_result.capabilities.experimental is not None
assert "tasks" in init_result.capabilities.experimental
tasks_cap = init_result.capabilities.experimental["tasks"]
assert tasks_cap == get_task_capabilities()["tasks"]
# Verify tasks capability is present as a first-class field (not experimental)
assert init_result.capabilities.tasks is not None
assert init_result.capabilities.tasks == get_task_capabilities()
# Verify it's NOT in experimental
assert "tasks" not in (init_result.capabilities.experimental or {})
async def test_client_uses_task_capable_session():