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
41 changes: 39 additions & 2 deletions nitrostack/core/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
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 @@ -954,7 +955,7 @@ def handle_server_discover(self, protocol_version: Optional[str] = None) -> Dict
# Same source of truth as `NitroStackMcpServer.get_capabilities`, so
# `server/discover` and real capability negotiation never disagree.
resources_subscribe = supports_resource_subscriptions(self.protocol_era)
return build_discover_result(
result = build_discover_result(
server_name=self.server_config.name,
server_version=self.server_config.version,
protocol_version=version,
Expand All @@ -963,6 +964,26 @@ def handle_server_discover(self, protocol_version: Optional[str] = None) -> Dict
advertise_app=has_widgets,
custom_extensions=self._custom_extensions(),
)
meta = dict(result.get("_meta") or {})
meta["io.modelcontextprotocol/serverInfo"] = {
"name": self.server_config.name,
"version": self.server_config.version,
}
result["_meta"] = meta
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."""
Expand Down Expand Up @@ -1022,6 +1043,11 @@ async def _list_tools(
_meta=self._list_endpoint_cache_meta(),
)

async def _ping(_ctx: ServerRequestContext, _params: Optional[RequestParams]):
if self.protocol_era == "legacy":
return types.EmptyResult()
raise MCPError(types.METHOD_NOT_FOUND, "ping was removed in MCP 2026-07-28")

async def _call_tool(ctx: ServerRequestContext, params: CallToolRequestParams):
token = request_ctx.set(ctx)
try:
Expand Down Expand Up @@ -1086,7 +1112,7 @@ async def _get_prompt(ctx: ServerRequestContext, params: GetPromptRequestParams)
params.name,
{str(key): str(value) for key, value in arguments.items()},
)

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 @@ -1646,6 +1672,17 @@ 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
2 changes: 2 additions & 0 deletions nitrostack/protocol/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ class JsonRpcErrorCode(IntEnum):
INVALID_PARAMS = -32602
INTERNAL_ERROR = -32603
HEADER_BODY_MISMATCH = -32020
MISSING_REQUIRED_CLIENT_CAPABILITY = -32021
UNSUPPORTED_PROTOCOL_VERSION = -32022


Expand All @@ -22,5 +23,6 @@ class JsonRpcErrorCode(IntEnum):
JsonRpcErrorCode.INVALID_PARAMS: "Invalid params",
JsonRpcErrorCode.INTERNAL_ERROR: "Internal error",
JsonRpcErrorCode.HEADER_BODY_MISMATCH: "Header/body mismatch",
JsonRpcErrorCode.MISSING_REQUIRED_CLIENT_CAPABILITY: "Missing required client capability",
JsonRpcErrorCode.UNSUPPORTED_PROTOCOL_VERSION: "Unsupported protocol version",
}
6 changes: 4 additions & 2 deletions tests/test_mcp22_official_http_owner.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,15 +78,17 @@ def test_health_endpoint_remains_available():
assert response.json()["status"] == "ok"


def test_official_mcp_handles_ping_without_a_default_session_id():
def test_official_mcp_ping_is_method_not_found_without_a_default_session_id():
response = _request(
"/mcp",
method="POST",
payload={"jsonrpc": "2.0", "id": 1, "method": "ping"},
)
assert response.status_code == 200
assert response.headers.get("mcp-session-id") is None
assert response.json() == {"jsonrpc": "2.0", "id": 1, "result": {}}
body = response.json()
assert body["error"]["code"] == -32601
assert "result" not in body


def test_official_mcp_returns_jsonrpc_error_for_unknown_method():
Expand Down
168 changes: 168 additions & 0 deletions tests/test_mcp26_wire.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
import asyncio
import json

import mcp.types as types
import pytest
from mcp import MCPError
from pydantic import BaseModel
from starlette.testclient import TestClient

from nitrostack import ExecutionContext, injectable, module, tool
from nitrostack.protocol.errors import JsonRpcErrorCode
from nitrostack.testing import NitroTestingModule

MODERN_VERSION = "2026-07-28"


class In(BaseModel):
q: str = ""


@injectable()
class C:
@tool(name="alpha", description="first", input_schema=In, task_support="optional")
async def alpha(self, input: In, context: ExecutionContext) -> str:
return "a"


@module(name="m", controllers=[C])
class M:
pass


def make_app(monkeypatch, era):
monkeypatch.setenv("NITRO_MCP_PROTOCOL_VERSION", era)
monkeypatch.delenv("MCP_STATELESS", raising=False)
harness = asyncio.run(NitroTestingModule.create(M))
return harness.app


def envelope(extra=None):
meta = {
"io.modelcontextprotocol/protocolVersion": MODERN_VERSION,
"io.modelcontextprotocol/clientCapabilities": {},
}
params = dict(extra or {})
params["_meta"] = {**params.get("_meta", {}), **meta}
return params


def rpc(client, method, params=None, *, headers=None, id_=1, envelope=True):
base = {
"Accept": "application/json, text/event-stream",
"Content-Type": "application/json",
}
base.update(headers or {})
p = dict(params or {})
if envelope:
meta = dict(p.get("_meta") or {})
meta.setdefault("io.modelcontextprotocol/protocolVersion", MODERN_VERSION)
meta.setdefault("io.modelcontextprotocol/clientCapabilities", {})
p["_meta"] = meta
body = {"jsonrpc": "2.0", "id": id_, "method": method, "params": p}
return client.post("/mcp", content=json.dumps(body), headers=base)

def body_of(resp):
ctype = resp.headers.get("content-type", "")
if "text/event-stream" in ctype:
for line in resp.text.splitlines():
if line.startswith("data:"):
return json.loads(line[5:].strip())
return {}
try:
return resp.json()
except ValueError:
return {}


def modern_headers(method, name=None):
h = {"MCP-Protocol-Version": MODERN_VERSION, "Mcp-Method": method}
if name:
h["Mcp-Name"] = name
return h


def error_code(payload):
return (payload.get("error") or {}).get("code")


def exc_code(exc):
err = getattr(exc, "error", None)
return getattr(err, "code", getattr(exc, "code", None))


@pytest.mark.parametrize("era", ["modern", "auto"])
def test_ping_is_method_not_found(monkeypatch, era):
app = make_app(monkeypatch, era)
with TestClient(app.get_combined_app()) as client:
resp = rpc(client, "ping", headers=modern_headers("ping"))
assert error_code(body_of(resp)) == -32601

def test_ping_still_answered_on_legacy(monkeypatch):
app = make_app(monkeypatch, "legacy")
app.protocol_era = "legacy"

async def go():
entry = app.mcp_server._request_handlers["ping"]
return await entry.handler(None, None)

result = asyncio.run(go())
assert isinstance(result, types.EmptyResult)


@pytest.mark.parametrize("era", ["modern", "auto"])
def test_discover_carries_server_info_in_meta(monkeypatch, era):
app = make_app(monkeypatch, era)
with TestClient(app.get_combined_app()) as client:
resp = rpc(client, "server/discover", headers=modern_headers("server/discover"))
assert resp.status_code == 200
assert "mcp-session-id" not in {k.lower() for k in resp.headers}
result = body_of(resp)["result"]
info = result["_meta"]["io.modelcontextprotocol/serverInfo"]
assert info["name"] and info["version"]
assert result["resultType"] == "complete"


def test_discover_keeps_cache_hint_in_meta(monkeypatch):
app = make_app(monkeypatch, "modern")
result = app.handle_server_discover()
assert "io.modelcontextprotocol/serverInfo" in result["_meta"]
assert len(result["_meta"]) >= 2 # serverInfo plus the existing cache hint


def test_initialize_is_not_served_on_modern(monkeypatch):
app = make_app(monkeypatch, "modern")
with TestClient(app.get_combined_app()) as client:
resp = rpc(
client,
"initialize",
{"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "t", "version": "1"}},
headers=modern_headers("initialize"),
)
assert error_code(body_of(resp)) == -32601


def test_tools_call_without_mcp_method_header_fails_closed(monkeypatch):
app = make_app(monkeypatch, "modern")
with TestClient(app.get_combined_app()) as client:
resp = rpc(
client,
"tools/call",
{"name": "alpha", "arguments": {}},
headers={"MCP-Protocol-Version": MODERN_VERSION},
)
assert resp.status_code >= 400 or error_code(body_of(resp)) == -32020


def test_missing_client_capability_code_is_defined():
assert -32021 in {int(member.value) for member in JsonRpcErrorCode}



def test_task_call_still_works_when_tasks_available(monkeypatch):
app = make_app(monkeypatch, "legacy")

async def go():
return await app._call_tool("alpha", {}, task=types.TaskMetadata(ttl=1000))

assert isinstance(asyncio.run(go()), types.CreateTaskResult)
Loading