Skip to content

Commit f6784bd

Browse files
committed
Merge branch 'main' into add_root_notification
2 parents c1789be + 3e798bf commit f6784bd

File tree

10 files changed

+119
-31
lines changed

10 files changed

+119
-31
lines changed

.github/CODEOWNERS

Lines changed: 0 additions & 23 deletions
This file was deleted.

examples/clients/simple-chatbot/mcp_simple_chatbot/main.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -291,8 +291,15 @@ async def process_llm_response(self, llm_response: str) -> str:
291291
"""
292292
import json
293293

294+
def _clean_json_string(json_string: str) -> str:
295+
"""Remove ```json ... ``` or ``` ... ``` wrappers if the LLM response is fenced."""
296+
import re
297+
298+
pattern = r"^```(?:\s*json)?\s*(.*?)\s*```$"
299+
return re.sub(pattern, r"\1", json_string, flags=re.DOTALL | re.IGNORECASE).strip()
300+
294301
try:
295-
tool_call = json.loads(llm_response)
302+
tool_call = json.loads(_clean_json_string(llm_response))
296303
if "tool" in tool_call and "arguments" in tool_call:
297304
logging.info(f"Executing tool: {tool_call['tool']}")
298305
logging.info(f"With arguments: {tool_call['arguments']}")

src/mcp/client/sse.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from anyio.abc import TaskStatus
99
from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream
1010
from httpx_sse import aconnect_sse
11+
from httpx_sse._exceptions import SSEError
1112

1213
import mcp.types as types
1314
from mcp.shared._httpx_utils import McpHttpClientFactory, create_mcp_http_client
@@ -105,6 +106,9 @@ async def sse_reader(
105106
await read_stream_writer.send(session_message)
106107
case _:
107108
logger.warning(f"Unknown SSE event: {sse.event}")
109+
except SSEError as sse_exc:
110+
logger.exception("Encountered SSE exception")
111+
raise sse_exc
108112
except Exception as exc:
109113
logger.exception("Error in sse_reader")
110114
await read_stream_writer.send(exc)

src/mcp/server/fastmcp/resources/resource_manager.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from mcp.server.fastmcp.resources.base import Resource
1111
from mcp.server.fastmcp.resources.templates import ResourceTemplate
1212
from mcp.server.fastmcp.utilities.logging import get_logger
13+
from mcp.types import Icon
1314

1415
if TYPE_CHECKING:
1516
from mcp.server.fastmcp.server import Context
@@ -61,6 +62,7 @@ def add_template(
6162
title: str | None = None,
6263
description: str | None = None,
6364
mime_type: str | None = None,
65+
icons: list[Icon] | None = None,
6466
) -> ResourceTemplate:
6567
"""Add a template from a function."""
6668
template = ResourceTemplate.from_function(
@@ -70,6 +72,7 @@ def add_template(
7072
title=title,
7173
description=description,
7274
mime_type=mime_type,
75+
icons=icons,
7376
)
7477
self._templates[template.uri_template] = template
7578
return template

src/mcp/server/fastmcp/resources/templates.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from mcp.server.fastmcp.resources.types import FunctionResource, Resource
1313
from mcp.server.fastmcp.utilities.context_injection import find_context_parameter, inject_context
1414
from mcp.server.fastmcp.utilities.func_metadata import func_metadata
15+
from mcp.types import Icon
1516

1617
if TYPE_CHECKING:
1718
from mcp.server.fastmcp.server import Context
@@ -27,6 +28,7 @@ class ResourceTemplate(BaseModel):
2728
title: str | None = Field(description="Human-readable title of the resource", default=None)
2829
description: str | None = Field(description="Description of what the resource does")
2930
mime_type: str = Field(default="text/plain", description="MIME type of the resource content")
31+
icons: list[Icon] | None = Field(default=None, description="Optional list of icons for the resource template")
3032
fn: Callable[..., Any] = Field(exclude=True)
3133
parameters: dict[str, Any] = Field(description="JSON schema for function parameters")
3234
context_kwarg: str | None = Field(None, description="Name of the kwarg that should receive context")
@@ -40,6 +42,7 @@ def from_function(
4042
title: str | None = None,
4143
description: str | None = None,
4244
mime_type: str | None = None,
45+
icons: list[Icon] | None = None,
4346
context_kwarg: str | None = None,
4447
) -> ResourceTemplate:
4548
"""Create a template from a function."""
@@ -67,6 +70,7 @@ def from_function(
6770
title=title,
6871
description=description or fn.__doc__ or "",
6972
mime_type=mime_type or "text/plain",
73+
icons=icons,
7074
fn=fn,
7175
parameters=parameters,
7276
context_kwarg=context_kwarg,
@@ -103,7 +107,7 @@ async def create_resource(
103107
title=self.title,
104108
description=self.description,
105109
mime_type=self.mime_type,
106-
icons=None, # Resource templates don't support icons
110+
icons=self.icons,
107111
fn=lambda: result, # Capture result in closure
108112
)
109113
except Exception as e:

src/mcp/server/fastmcp/server.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -335,6 +335,7 @@ async def list_resource_templates(self) -> list[MCPResourceTemplate]:
335335
title=template.title,
336336
description=template.description,
337337
mimeType=template.mime_type,
338+
icons=template.icons,
338339
)
339340
for template in templates
340341
]
@@ -559,7 +560,7 @@ def decorator(fn: AnyFunction) -> AnyFunction:
559560
title=title,
560561
description=description,
561562
mime_type=mime_type,
562-
# Note: Resource templates don't support icons
563+
icons=icons,
563564
)
564565
else:
565566
# Register as regular resource

src/mcp/shared/session.py

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -392,11 +392,17 @@ async def _receive_loop(self) -> None:
392392
# call it with the progress information
393393
if progress_token in self._progress_callbacks:
394394
callback = self._progress_callbacks[progress_token]
395-
await callback(
396-
notification.root.params.progress,
397-
notification.root.params.total,
398-
notification.root.params.message,
399-
)
395+
try:
396+
await callback(
397+
notification.root.params.progress,
398+
notification.root.params.total,
399+
notification.root.params.message,
400+
)
401+
except Exception as e:
402+
logging.error(
403+
"Progress callback raised an exception: %s",
404+
e,
405+
)
400406
await self._received_notification(notification)
401407
await self._handle_incoming(notification)
402408
except Exception as e:

src/mcp/types.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -470,6 +470,8 @@ class ResourceTemplate(BaseMetadata):
470470
The MIME type for all resources that match this template. This should only be
471471
included if all resources matching this template have the same type.
472472
"""
473+
icons: list[Icon] | None = None
474+
"""An optional list of icons for this resource template."""
473475
annotations: Annotations | None = None
474476
meta: dict[str, Any] | None = Field(alias="_meta", default=None)
475477
"""

tests/issues/test_1338_icons_and_metadata.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,12 @@ def test_prompt(text: str) -> str:
3939
"""A test prompt with an icon."""
4040
return text
4141

42+
# Create resource template with icon
43+
@mcp.resource("test://weather/{city}", icons=[test_icon])
44+
def test_resource_template(city: str) -> str:
45+
"""Get weather for a city."""
46+
return f"Weather for {city}"
47+
4248
# Test server metadata includes websiteUrl and icons
4349
assert mcp.name == "TestServer"
4450
assert mcp.website_url == "https://example.com"
@@ -75,6 +81,16 @@ def test_prompt(text: str) -> str:
7581
assert len(prompt.icons) == 1
7682
assert prompt.icons[0].src == test_icon.src
7783

84+
# Test resource template includes icon
85+
templates = await mcp.list_resource_templates()
86+
assert len(templates) == 1
87+
template = templates[0]
88+
assert template.name == "test_resource_template"
89+
assert template.uriTemplate == "test://weather/{city}"
90+
assert template.icons is not None
91+
assert len(template.icons) == 1
92+
assert template.icons[0].src == test_icon.src
93+
7894

7995
async def test_multiple_icons():
8096
"""Test that multiple icons can be added to tools, resources, and prompts."""

tests/shared/test_notifications.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import asyncio
22
from typing import Any, cast
3+
from unittest.mock import patch
34

45
import anyio
56
import pytest
@@ -11,6 +12,7 @@
1112
from mcp.server.models import InitializationOptions
1213
from mcp.server.session import ServerSession
1314
from mcp.shared.context import RequestContext
15+
from mcp.shared.memory import create_connected_server_and_client_session
1416
from mcp.shared.message import SessionMessage
1517
from mcp.shared.progress import progress
1618
from mcp.shared.session import BaseSession, RequestResponder
@@ -330,6 +332,72 @@ async def handle_client_message(
330332
assert server_progress_updates[3]["message"] == "Processing results..."
331333

332334

335+
@pytest.mark.anyio
336+
async def test_progress_callback_exception_logging():
337+
"""Test that exceptions in progress callbacks are logged and \
338+
don't crash the session."""
339+
# Track logged warnings
340+
logged_errors: list[str] = []
341+
342+
def mock_log_error(msg: str, *args: Any) -> None:
343+
logged_errors.append(msg % args if args else msg)
344+
345+
# Create a progress callback that raises an exception
346+
async def failing_progress_callback(progress: float, total: float | None, message: str | None) -> None:
347+
raise ValueError("Progress callback failed!")
348+
349+
# Create a server with a tool that sends progress notifications
350+
server = Server(name="TestProgressServer")
351+
352+
@server.call_tool()
353+
async def handle_call_tool(name: str, arguments: Any) -> list[types.TextContent]:
354+
if name == "progress_tool":
355+
# Send a progress notification
356+
await server.request_context.session.send_progress_notification(
357+
progress_token=server.request_context.request_id,
358+
progress=50.0,
359+
total=100.0,
360+
message="Halfway done",
361+
)
362+
return [types.TextContent(type="text", text="progress_result")]
363+
raise ValueError(f"Unknown tool: {name}")
364+
365+
@server.list_tools()
366+
async def handle_list_tools() -> list[types.Tool]:
367+
return [
368+
types.Tool(
369+
name="progress_tool",
370+
description="A tool that sends progress notifications",
371+
inputSchema={},
372+
)
373+
]
374+
375+
# Test with mocked logging
376+
with patch("mcp.shared.session.logging.error", side_effect=mock_log_error):
377+
async with create_connected_server_and_client_session(server) as client_session:
378+
# Send a request with a failing progress callback
379+
result = await client_session.send_request(
380+
types.ClientRequest(
381+
types.CallToolRequest(
382+
method="tools/call",
383+
params=types.CallToolRequestParams(name="progress_tool", arguments={}),
384+
)
385+
),
386+
types.CallToolResult,
387+
progress_callback=failing_progress_callback,
388+
)
389+
390+
# Verify the request completed successfully despite the callback failure
391+
assert len(result.content) == 1
392+
content = result.content[0]
393+
assert isinstance(content, types.TextContent)
394+
assert content.text == "progress_result"
395+
396+
# Check that a warning was logged for the progress callback exception
397+
assert len(logged_errors) > 0
398+
assert any("Progress callback raised an exception" in warning for warning in logged_errors)
399+
400+
333401
@pytest.mark.anyio
334402
async def test_initialized_notification():
335403
"""Test that the server receives and handles InitializedNotification."""

0 commit comments

Comments
 (0)