Skip to content

feat: add partial results as part of progress notifications #669

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/mcp/client/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -259,14 +259,15 @@ async def call_tool(
name: str,
arguments: dict[str, Any] | None = None,
read_timeout_seconds: timedelta | None = None,
**meta

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we model this as an argument with Meta type rather than treating it as **kwargs? If the idea is not to put all extra keyword arguments, that don't match other call_tool arguments, in meta then we should use an argument with type Meta instead.

) -> types.CallToolResult:
"""Send a tools/call request."""

return await self.send_request(
types.ClientRequest(
types.CallToolRequest(
method="tools/call",
params=types.CallToolRequestParams(name=name, arguments=arguments),
params=types.CallToolRequestParams(name=name, arguments=arguments, _meta=meta),
)
),
types.CallToolResult,
Expand Down
12 changes: 10 additions & 2 deletions src/mcp/server/fastmcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@
ImageContent,
TextContent,
ToolAnnotations,
PartialResult,
)
from mcp.types import Prompt as MCPPrompt
from mcp.types import PromptArgument as MCPPromptArgument
Expand Down Expand Up @@ -952,13 +953,14 @@ def request_context(self) -> RequestContext[ServerSessionT, LifespanContextT]:
return self._request_context

async def report_progress(
self, progress: float, total: float | None = None
self, progress: float, total: float | None = None, partial_result: PartialResult | None = None
) -> None:
"""Report progress for the current operation.

Args:
progress: Current progress value e.g. 24
total: Optional total value e.g. 100
partial_result: Optional partial result to include with the progress notification
"""

progress_token = (
Expand All @@ -967,11 +969,17 @@ async def report_progress(
else None
)

partial_result = (
partial_result
if self.request_context.meta and self.request_context.meta.partialResults
else None
)

if progress_token is None:
return

await self.request_context.session.send_progress_notification(
progress_token=progress_token, progress=progress, total=total
progress_token=progress_token, progress=progress, total=total, partial_result=partial_result
)

async def read_resource(self, uri: str | AnyUrl) -> Iterable[ReadResourceContents]:
Expand Down
2 changes: 2 additions & 0 deletions src/mcp/server/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,7 @@ async def send_progress_notification(
progress: float,
total: float | None = None,
related_request_id: str | None = None,
partial_result: types.PartialResult | None = None,
) -> None:
"""Send a progress notification."""
await self.send_notification(
Expand All @@ -289,6 +290,7 @@ async def send_progress_notification(
progressToken=progress_token,
progress=progress,
total=total,
partialResult=partial_result,
),
)
),
Expand Down
25 changes: 24 additions & 1 deletion src/mcp/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,12 @@ class Meta(BaseModel):
parameter is an opaque token that will be attached to any subsequent
notifications. The receiver is not obligated to provide these notifications.
"""

partialResults: bool | None = None

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Might be a comment for the spec PR.
Nit: I wonder if streamPartialResults or notifyPartialResults would be more apt than adding a boolean value under partialResults?

"""
If true, the caller is requesting that results be streamed via progress notifications.
When this is set to true, the final response may be empty as the complete result
will have been delivered through progress notifications.
"""
model_config = ConfigDict(extra="allow")

meta: Meta | None = Field(alias="_meta", default=None)
Expand Down Expand Up @@ -322,6 +327,19 @@ class PingRequest(Request[RequestParams | None, Literal["ping"]]):
method: Literal["ping"]
params: RequestParams | None = None

class PartialResult(BaseModel):
chunk: dict[str, Any]
Copy link

@000-000-000-000-000 000-000-000-000-000 May 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

disregard below -- these points need to go on the spec PR. i will add them there.

question:

  1. there is nothing technically wrong with modeling this as a dict[str, Any], but this is not how CallToolResult models its content. Just curious if there is a specific reason not to keep same structure as CallToolResult? Intuitively i would expect them to be very similar.
  2. Also a nitpick on naming but should we call this chunk? why not "content" or something (similar to CallToolResult)

"""The partial result data chunk."""
append: bool = False
"""
If true, this chunk should be appended to previously received chunks.
If false, this chunk replaces any previously received chunks.
"""
lastChunk: bool = False
"""
If true, this is the final chunk of the result.
No further chunks will be sent for this operation.
"""

class ProgressNotificationParams(NotificationParams):
"""Parameters for progress notifications."""
Expand All @@ -338,6 +356,11 @@ class ProgressNotificationParams(NotificationParams):
"""
total: float | None = None
"""Total number of items to process (or total progress required), if known."""
partialResult: PartialResult | None = None
"""
If present, contains a partial result chunk for the operation.
This is used to stream results incrementally while an operation is still in progress.
"""
model_config = ConfigDict(extra="allow")


Expand Down
47 changes: 44 additions & 3 deletions tests/issues/test_176_progress_token.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

from mcp.server.fastmcp import Context
from mcp.shared.context import RequestContext
from mcp.types import PartialResult

pytestmark = pytest.mark.anyio

Expand Down Expand Up @@ -39,11 +40,51 @@ async def test_progress_token_zero_first_call():
mock_session.send_progress_notification.call_count == 3
), "All progress notifications should be sent"
mock_session.send_progress_notification.assert_any_call(
progress_token=0, progress=0.0, total=10.0
progress_token=0, progress=0.0, total=10.0, partial_result=None
)
mock_session.send_progress_notification.assert_any_call(
progress_token=0, progress=5.0, total=10.0
progress_token=0, progress=5.0, total=10.0, partial_result=None
)
mock_session.send_progress_notification.assert_any_call(
progress_token=0, progress=10.0, total=10.0
progress_token=0, progress=10.0, total=10.0, partial_result=None
)

async def test_progress_token_with_partial_results():
"""Test that progress notifications with partial results set to true"""

# Create mock session with progress notification tracking
mock_session = AsyncMock()
mock_session.send_progress_notification = AsyncMock()

# Create request context with progress token and partialResults as True
mock_meta = MagicMock()
mock_meta.progressToken = "progress-token"
mock_meta.partialResults = True
request_context = RequestContext(
request_id="test-request",
session=mock_session,
meta=mock_meta,
lifespan_context=None,
)

# Create context with our mocks
ctx = Context(request_context=request_context, fastmcp=MagicMock())

# Test progress reporting
await ctx.report_progress(0, 10, PartialResult(chunk={"content": [{"type": "text", "text": "TestData1"}]}, append=False, lastChunk=False))
await ctx.report_progress(5, 10)
await ctx.report_progress(10, 10, PartialResult(chunk={"content": [{"type": "text", "text": "TestData3"}]}, append=True, lastChunk=True))

# Verify progress notifications
assert (
mock_session.send_progress_notification.call_count == 3
), "All progress notifications should be sent"
mock_session.send_progress_notification.assert_any_call(
progress_token="progress-token", progress=0.0, total=10.0, partial_result=PartialResult(chunk={"content": [{"type": "text", "text": "TestData1"}]}, append=False, lastChunk=False)
)
mock_session.send_progress_notification.assert_any_call(
progress_token="progress-token", progress=5.0, total=10.0, partial_result=None
)
mock_session.send_progress_notification.assert_any_call(
progress_token="progress-token", progress=10.0, total=10.0, partial_result=PartialResult(chunk={"content": [{"type": "text", "text": "TestData3"}]}, append=True, lastChunk=True)
)