Skip to content

Commit a5976f7

Browse files
committed
fix: MCP spec compliance and transport parity
- Remove invalid 'roots' field from ServerCapabilities per MCP spec 2025-06-18 Server capabilities should only include: prompts, resources, tools, logging, completions, and experimental. roots is a client capability, not server. - Add missing handlers to streamablehttp transport for feature parity with SSE/RPC: * list_resource_templates: Enables resources/templates/list via streamablehttp * set_logging_level: Adds logging/setLevel support * completion: Provides argument completion suggestions This ensures both transports advertise the same capabilities. - Fix resources/read in streamablehttp to return proper content: * Return blob content for binary resources * Return text content for text resources * Return empty string (not empty list) on errors for correct type signature * Fixes empty contents issue reported via MCP inspector - Update tests to match corrected return type (str/bytes instead of list) Closes issues found during MCP inspector testing: 1. Missing elicitation in capabilities (not needed - client capability) 2. Different capabilities between streamablehttp and SSE (now fixed) 3. resources/templates/list not working in streamablehttp (now fixed) 4. resources/read returning empty contents (now fixed) Signed-off-by: Mihai Criveti <[email protected]>
1 parent 1d54f29 commit a5976f7

File tree

3 files changed

+134
-17
lines changed

3 files changed

+134
-17
lines changed

mcpgateway/cache/session_registry.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1258,7 +1258,6 @@ async def handle_initialize_logic(self, body: Dict[str, Any], session_id: Option
12581258
tools={"listChanged": True},
12591259
logging={},
12601260
completions={}, # Advertise completions capability per MCP spec
1261-
roots={"listChanged": True}, # Advertise roots capability (roots/list now implemented)
12621261
),
12631262
serverInfo=Implementation(name=settings.app_name, version=__version__),
12641263
instructions=("MCP Gateway providing federated tools, resources and prompts. Use /admin interface for configuration."),

mcpgateway/transports/streamablehttp_transport.py

Lines changed: 124 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,8 @@
5555
# First-Party
5656
from mcpgateway.config import settings
5757
from mcpgateway.db import SessionLocal
58+
from mcpgateway.models import LogLevel
59+
from mcpgateway.services.completion_service import CompletionService
5860
from mcpgateway.services.logging_service import LoggingService
5961
from mcpgateway.services.prompt_service import PromptService
6062
from mcpgateway.services.resource_service import ResourceService
@@ -65,10 +67,11 @@
6567
logging_service = LoggingService()
6668
logger = logging_service.get_logger(__name__)
6769

68-
# Initialize ToolService, PromptService and MCP Server
70+
# Initialize ToolService, PromptService, ResourceService, CompletionService and MCP Server
6971
tool_service: ToolService = ToolService()
7072
prompt_service: PromptService = PromptService()
7173
resource_service: ResourceService = ResourceService()
74+
completion_service: CompletionService = CompletionService()
7275

7376
mcp_app: Server[Any] = Server("mcp-streamable-http")
7477

@@ -555,8 +558,8 @@ async def read_resource(resource_id: str) -> Union[str, bytes]:
555558
resource_id (str): The ID of the resource to read.
556559
557560
Returns:
558-
Union[str, bytes]: The content of the resource, typically as text.
559-
Returns an empty list on failure or if no content is found.
561+
Union[str, bytes]: The content of the resource as text or binary data.
562+
Returns empty string on failure or if no content is found.
560563
561564
Logs exceptions if any errors occur during reading.
562565
@@ -574,17 +577,130 @@ async def read_resource(resource_id: str) -> Union[str, bytes]:
574577
result = await resource_service.read_resource(db=db, resource_id=resource_id)
575578
except Exception as e:
576579
logger.exception(f"Error reading resource '{resource_id}': {e}")
577-
return []
578-
if not result or not result.text:
579-
logger.warning(f"No content returned by resource: {resource_id}")
580-
return []
580+
return ""
581+
582+
# Return blob content if available (binary resources)
583+
if result and result.blob:
584+
return result.blob
585+
586+
# Return text content if available (text resources)
587+
if result and result.text:
588+
return result.text
581589

582-
return result.text
590+
# No content found
591+
logger.warning(f"No content returned by resource: {resource_id}")
592+
return ""
583593
except Exception as e:
584594
logger.exception(f"Error reading resource '{resource_id}': {e}")
595+
return ""
596+
597+
598+
@mcp_app.list_resource_templates()
599+
async def list_resource_templates() -> List[types.ResourceTemplate]:
600+
"""
601+
Lists all resource templates available to the MCP Server.
602+
603+
Returns:
604+
List[types.ResourceTemplate]: A list of resource templates with their URIs and metadata.
605+
606+
Examples:
607+
>>> import inspect
608+
>>> sig = inspect.signature(list_resource_templates)
609+
>>> list(sig.parameters.keys())
610+
[]
611+
>>> sig.return_annotation.__origin__.__name__
612+
'list'
613+
"""
614+
try:
615+
async with get_db() as db:
616+
try:
617+
resource_templates = await resource_service.list_resource_templates(db)
618+
return resource_templates
619+
except Exception as e:
620+
logger.exception(f"Error listing resource templates: {e}")
621+
return []
622+
except Exception as e:
623+
logger.exception(f"Error listing resource templates: {e}")
585624
return []
586625

587626

627+
@mcp_app.set_logging_level()
628+
async def set_logging_level(level: types.LoggingLevel) -> types.EmptyResult:
629+
"""
630+
Sets the logging level for the MCP Server.
631+
632+
Args:
633+
level (types.LoggingLevel): The desired logging level (debug, info, notice, warning, error, critical, alert, emergency).
634+
635+
Returns:
636+
types.EmptyResult: An empty result indicating success.
637+
638+
Examples:
639+
>>> import inspect
640+
>>> sig = inspect.signature(set_logging_level)
641+
>>> list(sig.parameters.keys())
642+
['level']
643+
"""
644+
try:
645+
# Convert MCP logging level to our LogLevel enum
646+
level_map = {
647+
"debug": LogLevel.DEBUG,
648+
"info": LogLevel.INFO,
649+
"notice": LogLevel.INFO,
650+
"warning": LogLevel.WARNING,
651+
"error": LogLevel.ERROR,
652+
"critical": LogLevel.CRITICAL,
653+
"alert": LogLevel.CRITICAL,
654+
"emergency": LogLevel.CRITICAL,
655+
}
656+
log_level = level_map.get(level.lower(), LogLevel.INFO)
657+
await logging_service.set_level(log_level)
658+
return types.EmptyResult()
659+
except Exception as e:
660+
logger.exception(f"Error setting logging level: {e}")
661+
return types.EmptyResult()
662+
663+
664+
@mcp_app.completion()
665+
async def complete(ref: Union[types.PromptReference, types.ResourceReference], argument: types.CompleteRequest) -> types.CompleteResult:
666+
"""
667+
Provides argument completion suggestions for prompts or resources.
668+
669+
Args:
670+
ref (Union[types.PromptReference, types.ResourceReference]): Reference to the prompt or resource.
671+
argument (types.CompleteRequest): The completion request with partial argument value.
672+
673+
Returns:
674+
types.CompleteResult: Completion suggestions.
675+
676+
Examples:
677+
>>> import inspect
678+
>>> sig = inspect.signature(complete)
679+
>>> list(sig.parameters.keys())
680+
['ref', 'argument']
681+
"""
682+
try:
683+
async with get_db() as db:
684+
try:
685+
# Convert types to dict for completion service
686+
params = {
687+
"ref": ref.model_dump() if hasattr(ref, "model_dump") else ref,
688+
"argument": argument.model_dump() if hasattr(argument, "model_dump") else argument,
689+
}
690+
result = await completion_service.handle_completion(db, params)
691+
692+
# Convert result to CompleteResult
693+
if isinstance(result, dict):
694+
return types.CompleteResult(**result)
695+
return result
696+
except Exception as e:
697+
logger.exception(f"Error handling completion: {e}")
698+
return types.CompleteResult(completion=types.Completion(values=[], total=0, hasMore=False))
699+
except Exception as e:
700+
logger.exception(f"Error handling completion: {e}")
701+
return types.CompleteResult(completion=types.Completion(values=[], total=0, hasMore=False))
702+
703+
588704
class SessionManagerWrapper:
589705
"""
590706
Wrapper class for managing the lifecycle of a StreamableHTTPSessionManager instance.

tests/unit/mcpgateway/transports/test_streamablehttp_transport.py

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -717,6 +717,7 @@ async def test_read_resource_success(monkeypatch):
717717
mock_db = MagicMock()
718718
mock_result = MagicMock()
719719
mock_result.text = "resource content here"
720+
mock_result.blob = None # Explicitly set to None so text is returned
720721

721722
@asynccontextmanager
722723
async def fake_get_db():
@@ -733,7 +734,7 @@ async def fake_get_db():
733734

734735
@pytest.mark.asyncio
735736
async def test_read_resource_no_content(monkeypatch, caplog):
736-
"""Test read_resource returns [] and logs warning if no content."""
737+
"""Test read_resource returns empty string and logs warning if no content."""
737738
# Third-Party
738739
from pydantic import AnyUrl
739740

@@ -743,6 +744,7 @@ async def test_read_resource_no_content(monkeypatch, caplog):
743744
mock_db = MagicMock()
744745
mock_result = MagicMock()
745746
mock_result.text = ""
747+
mock_result.blob = None
746748

747749
@asynccontextmanager
748750
async def fake_get_db():
@@ -754,13 +756,13 @@ async def fake_get_db():
754756
test_uri = AnyUrl("file:///empty.txt")
755757
with caplog.at_level("WARNING"):
756758
result = await read_resource(test_uri)
757-
assert result == []
759+
assert result == ""
758760
assert "No content returned by resource: file:///empty.txt" in caplog.text
759761

760762

761763
@pytest.mark.asyncio
762764
async def test_read_resource_no_result(monkeypatch, caplog):
763-
"""Test read_resource returns [] and logs warning if no result."""
765+
"""Test read_resource returns empty string and logs warning if no result."""
764766
# Third-Party
765767
from pydantic import AnyUrl
766768

@@ -779,13 +781,13 @@ async def fake_get_db():
779781
test_uri = AnyUrl("file:///missing.txt")
780782
with caplog.at_level("WARNING"):
781783
result = await read_resource(test_uri)
782-
assert result == []
784+
assert result == ""
783785
assert "No content returned by resource: file:///missing.txt" in caplog.text
784786

785787

786788
@pytest.mark.asyncio
787789
async def test_read_resource_service_exception(monkeypatch, caplog):
788-
"""Test read_resource returns [] and logs exception from service."""
790+
"""Test read_resource returns empty string and logs exception from service."""
789791
# Third-Party
790792
from pydantic import AnyUrl
791793

@@ -804,13 +806,13 @@ async def fake_get_db():
804806
test_uri = AnyUrl("file:///error.txt")
805807
with caplog.at_level("ERROR"):
806808
result = await read_resource(test_uri)
807-
assert result == []
809+
assert result == ""
808810
assert "Error reading resource 'file:///error.txt': service error!" in caplog.text
809811

810812

811813
@pytest.mark.asyncio
812814
async def test_read_resource_outer_exception(monkeypatch, caplog):
813-
"""Test read_resource returns [] and logs exception from outer try-catch."""
815+
"""Test read_resource returns empty string and logs exception from outer try-catch."""
814816
# Standard
815817
from contextlib import asynccontextmanager
816818

@@ -831,7 +833,7 @@ async def failing_get_db():
831833
test_uri = AnyUrl("file:///db_error.txt")
832834
with caplog.at_level("ERROR"):
833835
result = await read_resource(test_uri)
834-
assert result == []
836+
assert result == ""
835837
assert "Error reading resource 'file:///db_error.txt': db error!" in caplog.text
836838

837839

0 commit comments

Comments
 (0)