Skip to content
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: 0 additions & 3 deletions nitrostack/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,6 @@
from nitrostack.tasks import InMemoryTaskStore, TaskAccessContext, TaskStore
from nitrostack.tasks.authorization import check_task_access, extract_task_access_context
from nitrostack.runtime import StatelessInvariants, assert_stateless_headers
from nitrostack.transports import wrap_stateless_transport, StatelessIngressPipeline


__all__ = [
Expand Down Expand Up @@ -242,6 +241,4 @@
"extract_task_access_context",
"StatelessInvariants",
"assert_stateless_headers",
"wrap_stateless_transport",
"StatelessIngressPipeline",
]
39 changes: 8 additions & 31 deletions nitrostack/core/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -789,10 +789,14 @@ def handle_server_discover(self, protocol_version: Optional[str] = None) -> Dict
getattr(self, "protocol_era", None),
self.server_config.protocol_version,
)
# Same source of truth as `NitroStackMcpServer.get_capabilities`, so
# `server/discover` and real capability negotiation never disagree.
resources_subscribe = getattr(self, "protocol_era", None) != "modern"
return build_discover_result(
server_name=self.server_config.name,
server_version=self.server_config.version,
protocol_version=version,
resources_subscribe=resources_subscribe,
advertise_tasks=self._advertise_tasks_extension(),
advertise_app=has_widgets,
custom_extensions=self._custom_extensions(),
Expand Down Expand Up @@ -1769,37 +1773,10 @@ def get_combined_app(
http_engine=http_engine,
)

if http_engine == "sessionless":
from nitrostack.transports.middleware import wrap_stateless_transport

def _discover_handler(_request):
return self.handle_server_discover()

def _initialize_handler(request):
params = getattr(request, "params", None) or {}
requested = params.get("protocolVersion") if isinstance(params, dict) else None
return self.handle_sessionless_initialize(
requested if isinstance(requested, str) else None
)

http_app = wrap_stateless_transport(
http_app,
server_name=self.server_config.name,
server_version=self.server_config.version,
protocol_version=protocol_version_for_era(era, self.server_config.protocol_version),
advertise_tasks=self._advertise_tasks_extension(),
advertise_app=any(
getattr(entry, "component", None) is not None
for entry in getattr(self, "_tools", {}).values()
),
custom_extensions=self._custom_extensions(),
wire_mode=wire_mode,
protocol_era=era,
enable_cors=enable_cors,
discover_handler=_discover_handler,
initialize_handler=_initialize_handler,
)

# The official MCP SDK owns the complete Streamable HTTP request lifecycle
# mounted by ``build_http_app``. Do not wrap it in a second JSON-RPC
# dispatcher: a sidecar would answer ping/initialize/tools/call itself and
# could diverge from the protocol and session behaviour of the SDK.
return http_app

async def _run_stdio(self) -> None:
Expand Down
6 changes: 5 additions & 1 deletion nitrostack/core/mcp_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ def __init__(self, name: str, version: Optional[str] = None):
self.has_task_support: bool = False
self.http_engine: Optional[str] = None
self.sessionful: bool = False
self.protocol_era: Optional[str] = None
self.discover_handler: Optional[Callable[[], dict[str, Any]]] = None
self.initialize_handler: Optional[Callable[[Optional[str]], dict[str, Any]]] = None
# In-process tests still look up handlers by request type.
Expand Down Expand Up @@ -67,7 +68,10 @@ def get_capabilities(
)

if caps.resources is not None:
caps.resources.subscribe = True
# `resources/subscribe` is only rejected on `modern`
# (see `rejects_deprecated_method`); `legacy` and `auto` both serve
# it live, so this must track era, not the sessionful/sessionless split.
caps.resources.subscribe = self.protocol_era != "modern"

if self.has_task_support:
caps.tasks = types.ServerTasksCapability(
Expand Down
2 changes: 0 additions & 2 deletions nitrostack/protocol/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,6 @@
from nitrostack.protocol.extensions import MCPExtensionId
from nitrostack.protocol.jsonrpc import (
JsonRpcWireError,
build_ping_response,
build_tool_error_result,
jsonrpc_error,
jsonrpc_success,
Expand Down Expand Up @@ -166,7 +165,6 @@
"build_sessionless_initialize_result",
"build_discover_result",
"parse_jsonrpc_request",
"build_ping_response",
"jsonrpc_success",
"jsonrpc_error",
"build_tool_error_result",
Expand Down
10 changes: 8 additions & 2 deletions nitrostack/protocol/discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,11 +36,17 @@ def build_discover_result(
custom_extensions: Optional[dict[str, str]] = None,
tools_list_changed: bool = True,
resources_list_changed: bool = True,
resources_subscribe: bool = False,
prompts_list_changed: bool = True,
ttl_ms: int = DEFAULT_LIST_CACHE_TTL_MS,
cache_scope: Literal["public", "private"] = "private",
) -> dict[str, Any]:
"""Build the ``server/discover`` result for the mounted HTTP engine."""
"""Build the ``server/discover`` result for the mounted HTTP engine.

``resources_subscribe`` must match ``NitroStackMcpServer.get_capabilities``
for the same era, so ``server/discover`` and real capability negotiation
never disagree on ``resources.subscribe``.
"""
versions = list(supported_versions or SUPPORTED_PROTOCOL_VERSIONS)
extensions: dict[str, dict[str, str]] = {}
if advertise_app:
Expand All @@ -53,7 +59,7 @@ def build_discover_result(

capabilities: dict[str, Any] = {
"tools": {"listChanged": tools_list_changed},
"resources": {"subscribe": False, "listChanged": resources_list_changed},
"resources": {"subscribe": resources_subscribe, "listChanged": resources_list_changed},
"prompts": {"listChanged": prompts_list_changed},
}
if extensions:
Expand Down
5 changes: 0 additions & 5 deletions nitrostack/protocol/jsonrpc.py
Original file line number Diff line number Diff line change
Expand Up @@ -239,11 +239,6 @@ def jsonrpc_error(request_id: Any, code: int, message: str, data: Any = None) ->
return {"jsonrpc": JSONRPC_VERSION, "id": request_id, "error": error}


def build_ping_response(request_id: Any) -> dict[str, Any]:
"""Ping fast path."""
return jsonrpc_success(request_id, {})


def build_tool_error_result(message: str, *, text_type: str = "text") -> dict[str, Any]:
"""
Tool business failure — JSON-RPC success with isError: true.
Expand Down
16 changes: 1 addition & 15 deletions nitrostack/transports/__init__.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,4 @@
"""MCP transport adapters (stdio, stateless HTTP)."""

from nitrostack.transports.dispatch import (
DispatchStage,
IngressContext,
StatelessIngressPipeline,
is_task_wire_interception,
)
from nitrostack.transports.middleware import StatelessTransportMiddleware, wrap_stateless_transport
"""MCP transport adapters (stdio and the official Streamable HTTP engine)."""
from nitrostack.transports.sse import format_sse_message, sse_connect_headers, sse_notification
from nitrostack.transports.subscriptions import (
http_listen_requires_auth,
Expand All @@ -15,12 +7,6 @@
)

__all__ = [
"DispatchStage",
"IngressContext",
"StatelessIngressPipeline",
"StatelessTransportMiddleware",
"wrap_stateless_transport",
"is_task_wire_interception",
"format_sse_message",
"sse_connect_headers",
"sse_notification",
Expand Down
Loading