Skip to content
Merged
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
68 changes: 12 additions & 56 deletions nitrostack/core/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@
from nitrostack.transforms import (
SyntheticTool, ToolArgumentError, TransformContext, validate_tool_arguments,
)
from nitrostack.protocol.errors import JsonRpcErrorCode
import mcp.types as types
from mcp import MCPError
from mcp.server.context import ServerRequestContext
Expand Down Expand Up @@ -457,7 +456,7 @@ def _read_contents_to_result(uri: str, items: List[ReadResourceContents]) -> typ
uri=uri, mimeType=mime, text=str(item.content), **extra
)
)
return types.ReadResourceResult(contents=contents)
return types.ReadResourceResult(contents=contents, resultType="complete")


def _http_headers_from_request_ctx(rc: Any) -> Dict[str, str]:
Expand Down Expand Up @@ -960,12 +959,9 @@ def _tasks_visible_on_wire(self) -> bool:
return self.protocol_era not in ("auto", "modern")

def _advertise_tasks_extension(self) -> bool:
if not self._tasks_visible_on_wire():
return False
return any(
entry.config.task_support in ("optional", "required")
for entry in self._tools.values()
)
# Legacy task handlers do not implement the complete 2026 extension.
# Advertising it is deferred to the dedicated tasks issue.
return False

def _custom_extensions(self) -> Optional[Dict[str, str]]:
extensions = getattr(self.server_config, "extensions", None)
Expand Down Expand Up @@ -1006,40 +1002,6 @@ def handle_server_discover(self, protocol_version: Optional[str] = None) -> Dict
result.setdefault("resultType", "complete")
return result

def _stamp_discover_meta(self, result: Dict[str, Any]) -> Dict[str, Any]:
"""2026 puts serverInfo and resultType on the result, keeping any cache hint in _meta."""
out = dict(result)
meta = dict(out.get("_meta") or {})
meta["io.modelcontextprotocol/serverInfo"] = {
"name": self.server_config.name,
"version": self.server_config.version,
}
out["_meta"] = meta
out.setdefault("resultType", "complete")
return out

def handle_sessionless_initialize(self, requested_version: Optional[str] = None) -> Dict[str, Any]:
"""Answer 2025 ``initialize`` on the sessionless ``auto`` path. No session."""
from nitrostack.protocol.discovery import build_sessionless_initialize_result

has_widgets = any(
getattr(entry, "component", None) is not None
for entry in getattr(self, "_tools", {}).values()
)
version = protocol_version_for_era(
getattr(self, "protocol_era", None),
self.server_config.protocol_version,
)
return build_sessionless_initialize_result(
server_name=self.server_config.name,
server_version=self.server_config.version,
requested_version=requested_version,
protocol_version=version,
advertise_tasks=self._advertise_tasks_extension(),
advertise_app=has_widgets,
custom_extensions=self._custom_extensions(),
)

def _list_endpoint_cache_meta(self) -> Dict[str, Any]:
return build_list_endpoint_cache_hint_meta()

Expand Down Expand Up @@ -1074,6 +1036,7 @@ async def _list_tools(
return types.ListToolsResult(
tools=tools,
_meta=self._list_endpoint_cache_meta(),
resultType="complete",
)

async def _ping(_ctx: ServerRequestContext, _params: Optional[RequestParams]):
Expand All @@ -1099,21 +1062,23 @@ async def _call_tool(ctx: ServerRequestContext, params: CallToolRequestParams):
},
isError=False,
)
return result
return result.model_copy(update={"result_type": result.result_type or "complete"})
finally:
request_ctx.reset(token)

async def _list_resources(_ctx: ServerRequestContext, _params: Optional[PaginatedRequestParams]):
return types.ListResourcesResult(
resources=[self._build_resource_definition(entry) for entry in self._resources.values()],
_meta=self._list_endpoint_cache_meta(),
resultType="complete",
)

async def _list_resource_templates(_ctx: ServerRequestContext, _params: Optional[PaginatedRequestParams]):
return types.ListResourceTemplatesResult(
resource_templates=[
self._build_resource_template_definition(entry) for entry in self._resource_templates
],
resultType="complete",
)

async def _read_resource(ctx: ServerRequestContext, params: ReadResourceRequestParams):
Expand All @@ -1137,14 +1102,17 @@ async def _list_prompts(_ctx: ServerRequestContext, _params: Optional[PaginatedR
return types.ListPromptsResult(
prompts=[self._build_prompt_definition(entry) for entry in self._prompts.values()],
_meta=self._list_endpoint_cache_meta(),
resultType="complete",
)

async def _get_prompt(ctx: ServerRequestContext, params: GetPromptRequestParams):
arguments = params.arguments or {}
return await self._get_prompt(
result = await self._get_prompt(
params.name,
{str(key): str(value) for key, value in arguments.items()},
)
return result.model_copy(update={"result_type": result.result_type or "complete"})

server.add_request_handler("ping", RequestParams, _ping)
async def _discover(ctx: ServerRequestContext, _params: Optional[RequestParams]):
return self.handle_server_discover(getattr(ctx, "protocol_version", None))
Expand Down Expand Up @@ -1214,7 +1182,6 @@ async def handle_get_prompt(req):

self._register_task_handlers(server)
self._register_initialized_handler(server)
server.discover_handler = self.handle_server_discover
server.middleware.insert(1, self._request_scope_middleware)

def _build_transform_context(self, rc: Any, *, invoker: Any) -> TransformContext:
Expand Down Expand Up @@ -1705,17 +1672,6 @@ async def _call_real_tool(self, name: str, arguments: Dict[str, Any], task: Any
trace = _trace_context_from_request_ctx(rc)

is_task = task_metadata is not None
if self.protocol_era in ("auto", "modern") and is_task:
raise MCPError(
types.INVALID_PARAMS,
"Server does not support task augmentation on the MCP 2026 wire",
)
if is_task and not getattr(self.mcp_server, "has_task_support", True):
raise MCPError(
JsonRpcErrorCode.MISSING_REQUIRED_CLIENT_CAPABILITY,
f"Tasks are not available on this protocol version; tool '{cfg.name}' "
"cannot be run as a task.",
)
if self.protocol_era in ("auto", "modern") and is_task:
raise MCPError(
types.INVALID_PARAMS,
Expand Down
34 changes: 21 additions & 13 deletions nitrostack/core/mcp_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from mcp.server.lowlevel.server import NotificationOptions
from mcp.server.subscriptions import InMemorySubscriptionBus, ListenHandler
from nitrostack.protocol.errors import JsonRpcErrorCode
from nitrostack.protocol.deprecated import deprecated_method_message, rejects_deprecated_method
from nitrostack.protocol.version import (
MODERN_PROTOCOL_VERSION,
ProtocolEra,
Expand All @@ -39,20 +40,24 @@ def __init__(self, name: str, version: Optional[str] = None):
name=name,
version=version or "",
on_subscriptions_listen=self.listen_handler,
on_ping=self._handle_ping,
)
self.has_task_support: bool = False
self.http_engine: Optional[str] = None
self.sessionful: bool = False
self.protocol_era: Optional[ProtocolEra] = None
self.middleware.append(self._enforce_protocol_era)
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.
self.request_handlers: Dict[Any, Callable[..., Any]] = {}
self.notification_handlers: Dict[Any, Callable[..., Any]] = {}

async def _enforce_protocol_era(self, context, call_next):
"""Apply era policy inside the official runner, after its JSON-RPC parsing."""
if (
context.protocol_version == MODERN_PROTOCOL_VERSION
and rejects_deprecated_method(context.method, "modern")
):
raise MCPError(types.METHOD_NOT_FOUND, deprecated_method_message(context.method))
if self.protocol_era == "modern" and context.method in (
"initialize", "notifications/initialized",
):
Expand All @@ -61,6 +66,15 @@ async def _enforce_protocol_era(self, context, call_next):
meta = flatten_request_meta_object(context.meta)
header_version = request_headers.get("mcp-protocol-version")
meta_version = meta.get("io.modelcontextprotocol/protocolVersion")
if (
meta_version == MODERN_PROTOCOL_VERSION
and context.request is not None
and not header_version
):
raise MCPError(
int(JsonRpcErrorCode.HEADER_BODY_MISMATCH),
"MCP-Protocol-Version header is required",
)
try:
validate_protocol_version_header_meta(header_version, meta_version)
except HeaderBodyMismatchError as exc:
Expand All @@ -78,17 +92,11 @@ async def _enforce_protocol_era(self, context, call_next):
raise MCPError(int(JsonRpcErrorCode.UNSUPPORTED_PROTOCOL_VERSION), "Unsupported protocol version")
return await call_next(context)

def handle_server_discover(self) -> dict[str, Any]:
"""Answer ``server/discover`` from this server instance."""
if self.discover_handler is None:
raise RuntimeError("server/discover is not configured on this server")
return self.discover_handler()

def handle_sessionless_initialize(self, requested_version: Optional[str] = None) -> dict[str, Any]:
"""Answer sessionless ``initialize`` from this server instance."""
if self.initialize_handler is None:
raise RuntimeError("sessionless initialize is not configured on this server")
return self.initialize_handler(requested_version)
async def _handle_ping(self, _context, _params):
"""Keep the official ping hook only for the legacy protocol era."""
if self.protocol_era != "legacy":
raise MCPError(types.METHOD_NOT_FOUND, "Method not found: ping")
return types.EmptyResult()

def get_capabilities(
self,
Expand Down
18 changes: 0 additions & 18 deletions nitrostack/protocol/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,19 +14,11 @@
from nitrostack.protocol.deprecated import deprecated_method_message, rejects_deprecated_method
from nitrostack.protocol.method_contract import (
DEPRECATED_MODERN_METHODS,
MODERN_METHOD_CONTRACTS,
MethodContract,
contract_for,
mcp_method_is_required,
mcp_name_field,
mcp_name_is_required,
)
from nitrostack.protocol.discovery import (
DISCOVER_RESULT_TYPE,
INITIALIZE_METHOD,
SERVER_DISCOVER_METHOD,
build_discover_result,
build_sessionless_initialize_result,
)
from nitrostack.protocol.errors import ERROR_CODE_MESSAGES, JsonRpcErrorCode
from nitrostack.protocol.extensions import MCPExtensionId
Expand All @@ -38,7 +30,6 @@
map_exception_to_jsonrpc,
parse_jsonrpc_request,
)
from nitrostack.protocol.layers import RuntimeLayer
from nitrostack.protocol.cache_hints import (
build_list_endpoint_cache_hint_meta,
resolve_resource_cache_hint_meta,
Expand Down Expand Up @@ -135,7 +126,6 @@
"supported_protocol_versions_for_era",
"wire_mode_for_era",
"MCPExtensionId",
"RuntimeLayer",
"LEGACY_SESSION_HEADER",
"MAX_CIMD_BYTES",
"MAX_SCHEMA_DEPTH",
Expand All @@ -153,16 +143,8 @@
"deprecated_method_message",
"rejects_deprecated_method",
"DEPRECATED_MODERN_METHODS",
"MODERN_METHOD_CONTRACTS",
"MethodContract",
"contract_for",
"mcp_method_is_required",
"mcp_name_field",
"mcp_name_is_required",
"DISCOVER_RESULT_TYPE",
"INITIALIZE_METHOD",
"SERVER_DISCOVER_METHOD",
"build_sessionless_initialize_result",
"build_discover_result",
"parse_jsonrpc_request",
"jsonrpc_success",
Expand Down
58 changes: 11 additions & 47 deletions nitrostack/protocol/discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,19 +10,10 @@

from nitrostack.protocol.cache_hints import DEFAULT_LIST_CACHE_TTL_MS, build_list_endpoint_cache_hint_meta
from nitrostack.protocol.extensions import MCPExtensionId
from nitrostack.protocol.version import (
LEGACY_PROTOCOL_VERSION,
MODERN_PROTOCOL_VERSION,
SUPPORTED_PROTOCOL_VERSIONS,
)
from nitrostack.protocol.version import MODERN_PROTOCOL_VERSION, SUPPORTED_PROTOCOL_VERSIONS

SERVER_DISCOVER_METHOD = "server/discover"
DISCOVER_RESULT_TYPE = "complete"
INITIALIZE_METHOD = "initialize"
INITIALIZED_NOTIFICATION = "notifications/initialized"
_SESSIONLESS_INITIALIZE_VERSIONS = frozenset(
{LEGACY_PROTOCOL_VERSION, MODERN_PROTOCOL_VERSION, "2025-11-25"}
)


def build_discover_result(
Expand All @@ -31,7 +22,7 @@ def build_discover_result(
server_version: str,
protocol_version: str = MODERN_PROTOCOL_VERSION,
supported_versions: Optional[tuple[str, ...]] = None,
advertise_tasks: bool = True,
advertise_tasks: bool = False,
advertise_app: bool = False,
custom_extensions: Optional[dict[str, str]] = None,
tools_list_changed: bool = True,
Expand All @@ -54,7 +45,7 @@ def build_discover_result(
if advertise_tasks:
extensions[MCPExtensionId.TASKS.value] = {"version": protocol_version}
for extension_id, version in (custom_extensions or {}).items():
if extension_id and version:
if extension_id and version and (extension_id != MCPExtensionId.TASKS.value or advertise_tasks):
extensions[str(extension_id)] = {"version": str(version)}

capabilities: dict[str, Any] = {
Expand All @@ -65,46 +56,19 @@ def build_discover_result(
if extensions:
capabilities["extensions"] = extensions

server_info = {"name": server_name, "version": server_version}
# 2026-07-28 requires `serverInfo` on the result `_meta`; the top-level
# copy stays for `auto`/legacy clients that read it from the body.
meta = build_list_endpoint_cache_hint_meta(ttl_ms=ttl_ms, cache_scope=cache_scope)
meta["serverInfo"] = server_info

return {
"resultType": DISCOVER_RESULT_TYPE,
"protocolVersion": protocol_version,
"supportedVersions": versions,
"serverInfo": {"name": server_name, "version": server_version},
"serverInfo": server_info,
"capabilities": capabilities,
"ttlMs": ttl_ms,
"cacheScope": cache_scope,
"_meta": build_list_endpoint_cache_hint_meta(ttl_ms=ttl_ms, cache_scope=cache_scope),
}


def build_sessionless_initialize_result(
*,
server_name: str,
server_version: str,
requested_version: Optional[str] = None,
protocol_version: str = MODERN_PROTOCOL_VERSION,
advertise_tasks: bool = True,
advertise_app: bool = False,
custom_extensions: Optional[dict[str, str]] = None,
) -> dict[str, Any]:
"""
Initialize-shaped result for era ``auto`` with no session.

Official v2 ``legacy: 'stateless'`` is not mounted yet. This adapter
answers 2025 ``initialize`` on the same engine as ``server/discover``.
"""
requested = (requested_version or "").strip()
negotiated = requested if requested in _SESSIONLESS_INITIALIZE_VERSIONS else protocol_version
discovered = build_discover_result(
server_name=server_name,
server_version=server_version,
protocol_version=protocol_version,
advertise_tasks=advertise_tasks,
advertise_app=advertise_app,
custom_extensions=custom_extensions,
)
return {
"protocolVersion": negotiated,
"capabilities": discovered["capabilities"],
"serverInfo": discovered["serverInfo"],
"_meta": meta,
}
Loading
Loading