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
4 changes: 3 additions & 1 deletion server/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ require (
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
github.com/mervick/aes-everywhere/go/aes256 v0.0.0-20240803013625-6759956693c0
github.com/modelcontextprotocol/go-sdk v1.6.1
github.com/modelcontextprotocol/go-sdk v1.7.0
github.com/pion/dtls/v3 v3.1.2
github.com/pion/rtp v1.8.18
github.com/pion/webrtc/v4 v4.0.1
Expand Down Expand Up @@ -80,7 +80,9 @@ require (
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect
golang.org/x/net v0.47.0 // indirect
golang.org/x/oauth2 v0.35.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/text v0.31.0 // indirect
golang.org/x/time v0.15.0 // indirect
google.golang.org/protobuf v1.34.1 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
)
8 changes: 6 additions & 2 deletions server/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,8 @@ github.com/mervick/aes-everywhere/go/aes256 v0.0.0-20240803013625-6759956693c0 h
github.com/mervick/aes-everywhere/go/aes256 v0.0.0-20240803013625-6759956693c0/go.mod h1:Eb5RMoo9kOQra/2uRiUTGP+LfNuM13Vqm7y7P34+KKo=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/modelcontextprotocol/go-sdk v1.6.1 h1:0zOSupjKUxPKSocPT1Wtago+mUHU2/uZ4xSOY0FGReU=
github.com/modelcontextprotocol/go-sdk v1.6.1/go.mod h1:kzm3kzFL1/+AziGOE0nUs3gvPoNxMCvkxokMkuFapXQ=
github.com/modelcontextprotocol/go-sdk v1.7.0 h1:yqjY2dsbKAC0LSuWZVBMrHgiG8ukXv6NRo0JiALay44=
github.com/modelcontextprotocol/go-sdk v1.7.0/go.mod h1:dL7u98E/zjJTGzEq+j30jQ8K2k1mb6LeAH4inEcSGts=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
Expand Down Expand Up @@ -179,13 +179,17 @@ golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
Expand Down
123 changes: 107 additions & 16 deletions server/service/mcp/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package mcpservice
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
Expand All @@ -16,6 +17,13 @@ import (
"NanoKVM-Server/service/inputcontrol"
)

const (
// legacyProtocolVersion is an older revision that the handler still answers.
legacyProtocolVersion = "2025-03-26"
// latestProtocolVersion is the newest revision that the handler must serve.
latestProtocolVersion = "2026-07-28"
)

type fakeSnapshotter struct {
snapshot Snapshot
err error
Expand Down Expand Up @@ -94,14 +102,13 @@ func TestMCPInitializeAndToolsList(t *testing.T) {
OK: true, Width: 800, Height: 600, JPEG: []byte{0xff, 0xd8, 0xff},
}})

request := func(method string, body string, sessionID string) *httptest.ResponseRecorder {
request := func(method string, body string, protocolVersion string) *httptest.ResponseRecorder {
recorder := httptest.NewRecorder()
req := httptest.NewRequest(method, "/api/mcp", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json, text/event-stream")
if sessionID != "" {
req.Header.Set("Mcp-Session-Id", sessionID)
req.Header.Set("Mcp-Protocol-Version", "2025-03-26")
if protocolVersion != "" {
req.Header.Set("Mcp-Protocol-Version", protocolVersion)
}
handler.ServeHTTP(recorder, req)
return recorder
Expand All @@ -114,17 +121,21 @@ func TestMCPInitializeAndToolsList(t *testing.T) {
if contentType := initialize.Header().Get("Content-Type"); !strings.HasPrefix(contentType, "application/json") {
t.Fatalf("initialize content type=%q, want application/json", contentType)
}
sessionID := initialize.Header().Get("Mcp-Session-Id")
if sessionID == "" || !strings.Contains(initialize.Body.String(), "nanokvm-cube-remote-control") {
t.Fatalf("session=%q body=%s", sessionID, initialize.Body.String())
if !strings.Contains(initialize.Body.String(), "nanokvm-cube-remote-control") {
t.Fatalf("initialize body=%s", initialize.Body.String())
}
// A stateless server issues no session ID. A legacy client that sends one
// is answered without it, and DELETE is not a method it can use.
if sessionID := initialize.Header().Get("Mcp-Session-Id"); sessionID != "" {
t.Fatalf("stateless initialize returned session %q, want none", sessionID)
}

initialized := request(http.MethodPost, `{"jsonrpc":"2.0","method":"notifications/initialized","params":{}}`, sessionID)
initialized := request(http.MethodPost, `{"jsonrpc":"2.0","method":"notifications/initialized","params":{}}`, legacyProtocolVersion)
if initialized.Code != http.StatusAccepted {
t.Fatalf("initialized status=%d body=%s", initialized.Code, initialized.Body.String())
}

tools := request(http.MethodPost, `{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}`, sessionID)
tools := request(http.MethodPost, `{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}`, legacyProtocolVersion)
if tools.Code != http.StatusOK {
t.Fatalf("tools/list status=%d body=%s", tools.Code, tools.Body.String())
}
Expand Down Expand Up @@ -153,7 +164,7 @@ func TestMCPInitializeAndToolsList(t *testing.T) {
}
for _, call := range calls {
body := `{"jsonrpc":"2.0","id":` + fmt.Sprint(call.id) + `,"method":"tools/call","params":{"name":"` + call.name + `","arguments":` + call.arguments + `}}`
response := request(http.MethodPost, body, sessionID)
response := request(http.MethodPost, body, legacyProtocolVersion)
if response.Code != http.StatusOK || strings.Contains(response.Body.String(), `"isError":true`) {
t.Fatalf("tools/call %s status=%d body=%s", call.name, response.Code, response.Body.String())
}
Expand Down Expand Up @@ -182,18 +193,98 @@ func TestMCPInitializeAndToolsList(t *testing.T) {
}
for _, call := range invalidCalls {
body := `{"jsonrpc":"2.0","id":` + fmt.Sprint(call.id) + `,"method":"tools/call","params":{"name":"` + call.name + `","arguments":` + call.arguments + `}}`
response := request(http.MethodPost, body, sessionID)
response := request(http.MethodPost, body, legacyProtocolVersion)
if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), `"isError":true`) {
t.Fatalf("invalid tools/call %s status=%d body=%s", call.name, response.Code, response.Body.String())
}
}

closed := request(http.MethodDelete, "", sessionID)
if closed.Code != http.StatusNoContent {
closed := request(http.MethodDelete, "", legacyProtocolVersion)
if closed.Code != http.StatusMethodNotAllowed {
t.Fatalf("delete status=%d body=%s", closed.Code, closed.Body.String())
}
}

// TestMCPHandlerServesLatestProtocol pins the newest protocol revision that the
// handler serves. The streamable HTTP transport offers 2026-07-28 only to a
// stateless handler; a stateful one negotiates down to 2025-11-25 and the
// revision becomes unreachable. The test therefore guards the handler options,
// not the SDK. It also fails when an SDK update adds a newer revision, which is
// the point: the new revision needs the same deliberate check.
func TestMCPHandlerServesLatestProtocol(t *testing.T) {
hid := &recordingHID{}
control := controlmode.NewManager(filepath.Join(t.TempDir(), "mode"), controlmode.ModeMCP)
handler := newMCPHandler(control, &inputcontrol.Coordinator{}, newRemoteWithHID(hid), nil)

const meta = `"_meta":{` +
`"io.modelcontextprotocol/protocolVersion":"` + latestProtocolVersion + `",` +
`"io.modelcontextprotocol/clientInfo":{"name":"test","version":"1"},` +
`"io.modelcontextprotocol/clientCapabilities":{}}`

// The revision mirrors the method, and the tool name, into HTTP headers so
// that intermediaries route without reading the body. A client that omits
// either header gets -32020, so every caller must send them.
post := func(protocolVersion string, method string, name string, body string) *httptest.ResponseRecorder {
recorder := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/mcp", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json, text/event-stream")
req.Header.Set("Mcp-Protocol-Version", protocolVersion)
req.Header.Set("Mcp-Method", method)
if name != "" {
req.Header.Set("Mcp-Name", name)
}
handler.ServeHTTP(recorder, req)
return recorder
}

discover := post(latestProtocolVersion, "server/discover", "", `{"jsonrpc":"2.0","id":1,"method":"server/discover","params":{`+meta+`}}`)
if discover.Code != http.StatusOK {
t.Fatalf("discover status=%d body=%s", discover.Code, discover.Body.String())
}
var discovered struct {
Result struct {
SupportedVersions []string `json:"supportedVersions"`
Capabilities struct {
Tools *struct{} `json:"tools"`
} `json:"capabilities"`
} `json:"result"`
Error *struct {
Code int `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
if err := json.Unmarshal(discover.Body.Bytes(), &discovered); err != nil {
t.Fatalf("discover body=%s: %v", discover.Body.String(), err)
}
if discovered.Error != nil {
t.Fatalf("discover error=%+v", discovered.Error)
}
// The SDK reports the supported versions newest first.
if len(discovered.Result.SupportedVersions) == 0 || discovered.Result.SupportedVersions[0] != latestProtocolVersion {
t.Fatalf("supported versions = %v, want %s first", discovered.Result.SupportedVersions, latestProtocolVersion)
}
if discovered.Result.Capabilities.Tools == nil {
t.Fatalf("discover advertises no tools: %s", discover.Body.String())
}

// The revision has no handshake: a tool call carrying `_meta` is enough.
call := post(latestProtocolVersion, "tools/call", "cube_move_mouse", `{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"cube_move_mouse","arguments":{"mode":"absolute","x":0.5,"y":0.5},`+meta+`}}`)
if call.Code != http.StatusOK || strings.Contains(call.Body.String(), `"isError":true`) {
t.Fatalf("tools/call status=%d body=%s", call.Code, call.Body.String())
}
if len(hid.absolute) != 1 {
t.Fatalf("absolute writes = %d, want 1", len(hid.absolute))
}

// The same endpoint keeps serving the older revisions, which have no
// `server/discover`. The two revisions must not leak into each other.
legacyDiscover := post(legacyProtocolVersion, "server/discover", "", `{"jsonrpc":"2.0","id":3,"method":"server/discover","params":{}}`)
if legacyDiscover.Code != http.StatusOK || !strings.Contains(legacyDiscover.Body.String(), "-32601") {
t.Fatalf("legacy discover status=%d body=%s, want method not found", legacyDiscover.Code, legacyDiscover.Body.String())
}
}

func TestMCPHandlerRejectsCrossOriginBrowserRequests(t *testing.T) {
control := controlmode.NewManager(filepath.Join(t.TempDir(), "mode"), controlmode.ModeMCP)
handler := http.NewCrossOriginProtection().Handler(newMCPHandler(control, &inputcontrol.Coordinator{}, newRemoteWithHID(&recordingHID{}), nil))
Expand Down Expand Up @@ -227,10 +318,10 @@ func TestMCPHandlerRejectsOversizedRequestBody(t *testing.T) {
req.Header.Set("Accept", "application/json, text/event-stream")

handler.ServeHTTP(recorder, req)
if recorder.Code == http.StatusOK {
t.Fatalf("oversized request was accepted: status=%d", recorder.Code)
if recorder.Code != http.StatusRequestEntityTooLarge {
t.Fatalf("oversized request status=%d, want 413: body=%q", recorder.Code, recorder.Body.String())
}
if !strings.Contains(recorder.Body.String(), "failed to read body") {
if !strings.Contains(recorder.Body.String(), fmt.Sprintf("request body exceeds %d bytes", maxRequestBodyBytes)) {
t.Fatalf("unexpected oversized response: status=%d body=%q", recorder.Code, recorder.Body.String())
}
}
Expand Down