mirror of
https://github.com/sipeed/NanoKVM.git
synced 2026-09-11 00:22:56 -05:00
commit 3dea4b1cfcb866d50c9c093b797c0f4f48fec23f Author: wenjie <meetwenjie@gmail.com> Date: Fri Apr 10 17:11:07 2026 +0800 fix(picoclaw): clean temp media and surface MCP screenshots in chat - remove shared load_image staging and pass source paths through directly - delete /tmp/picoclaw_media when a gateway session closes - push MCP screenshot observations to downstream websocket clients - hide null tool feedback messages and auto-scroll when screenshots load commit f58ffe1e27f83f64e2b0e8c2f8dcac46af715ace Author: wenjie <meetwenjie@gmail.com> Date: Fri Apr 10 15:55:59 2026 +0800 Harden PicoClaw local API auth and session locking Split PicoClaw routes by caller trust level and require the internal loopback token for local-only endpoints. Update the NanoKVM bridge script to send the internal token for loopback requests. Relax session lock acquisition for screenshot and action calls so the active session can perform local operations without permanently taking over the lock, and stop forcing the runtime dm_scope default. commit 462a0670bbc5138c893fbd1155e72465b82c2065 Author: wenjie <meetwenjie@gmail.com> Date: Fri Apr 10 13:52:39 2026 +0800 fix(picoclaw): improve KVM reliability and sidebar behavior - follow HTTPS loopback redirects in the NanoKVM bridge script - enable MJPEG frame caching only during active PicoClaw gateway sessions - restore legacy screen zoom behavior across MJPEG and H264 renderers - keep the PicoClaw sidebar available on mobile without splitter layout conflicts - hide empty "null"/"undefined" chat messages and keep MJPEG failures on a black screen commit bfda85c6368825c7ddf47d347ba5ec2588135885 Author: wenjie <meetwenjie@gmail.com> Date: Fri Apr 10 11:37:15 2026 +0800 fix(picoclaw): verify runtime downloads and remove unused config API - verify the downloaded runtime archive against the published SHA-512 checksum - remove the unused /api/picoclaw/config endpoints and related frontend state - rename runtime_control.go to runtime_constants.go for clearer intent commit 10b34aaea59e10d70c3cdecb01915444561ceaf7 Author: wenjie <meetwenjie@gmail.com> Date: Thu Apr 9 16:32:30 2026 +0800 fix: stabilize picoclaw runtime defaults and secure local MCP access - persist required NanoKVM startup defaults before launching picoclaw and after saving model config - force-enable the pico channel when loading config so runtime status can recover to ready - derive the local MCP URL from the configured HTTP port and keep loopback-only HTTP access behind an internal token - move loopback HTTP redirect logic into middleware to simplify main server startup - improve runtime/sidebar state handling and add the load-image endpoint for active picoclaw sessions commit d20540195bf45ea290f727b473ee8cd2a1bbb68d Author: wenjie <meetwenjie@gmail.com> Date: Fri Mar 27 17:55:29 2026 +0800 feat(picoclaw): add picoclaw integration
300 lines
8.3 KiB
Go
300 lines
8.3 KiB
Go
package picoclaw
|
|
|
|
import (
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
log "github.com/sirupsen/logrus"
|
|
)
|
|
|
|
// JSON-RPC 2.0 types for MCP HTTP transport
|
|
|
|
type jsonRPCRequest struct {
|
|
JSONRPC string `json:"jsonrpc"`
|
|
ID json.RawMessage `json:"id"`
|
|
Method string `json:"method"`
|
|
Params json.RawMessage `json:"params,omitempty"`
|
|
}
|
|
|
|
type jsonRPCResponse struct {
|
|
JSONRPC string `json:"jsonrpc"`
|
|
ID json.RawMessage `json:"id"`
|
|
Result interface{} `json:"result,omitempty"`
|
|
Error *jsonRPCError `json:"error,omitempty"`
|
|
}
|
|
|
|
type jsonRPCError struct {
|
|
Code int `json:"code"`
|
|
Message string `json:"message"`
|
|
}
|
|
|
|
// MCP tool definitions
|
|
|
|
var mcpToolDefinitions = []map[string]interface{}{
|
|
{
|
|
"name": "kvm_screenshot",
|
|
"description": "Capture the current HDMI frame from the downstream remote host as a base64-encoded JPEG image.",
|
|
"inputSchema": map[string]interface{}{
|
|
"type": "object",
|
|
"properties": map[string]interface{}{
|
|
"width": map[string]interface{}{"type": "integer", "description": "Target width in pixels (optional, default: 960)"},
|
|
"height": map[string]interface{}{"type": "integer", "description": "Target height in pixels (optional)"},
|
|
"quality": map[string]interface{}{"type": "integer", "description": "JPEG quality 1-100 (optional, default: 60)"},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
"name": "kvm_actions",
|
|
"description": "Send one or more HID actions (click, type, hotkey, scroll, drag, move, wait) to the downstream remote host. Use normalized [0,1] coordinates for mouse actions.",
|
|
"inputSchema": map[string]interface{}{
|
|
"type": "object",
|
|
"properties": map[string]interface{}{
|
|
"actions": map[string]interface{}{
|
|
"type": "array",
|
|
"description": "Array of action objects. Each requires an 'action' field (click, move, type, hotkey, scroll, drag, wait).",
|
|
"items": map[string]interface{}{
|
|
"type": "object",
|
|
"properties": map[string]interface{}{
|
|
"action": map[string]interface{}{"type": "string"},
|
|
"x": map[string]interface{}{"type": "number"},
|
|
"y": map[string]interface{}{"type": "number"},
|
|
"button": map[string]interface{}{"type": "string"},
|
|
"text": map[string]interface{}{"type": "string"},
|
|
"keys": map[string]interface{}{"type": "array", "items": map[string]interface{}{"type": "string"}},
|
|
"direction": map[string]interface{}{"type": "string"},
|
|
"amount": map[string]interface{}{"type": "integer"},
|
|
"duration_ms": map[string]interface{}{"type": "integer"},
|
|
"from": map[string]interface{}{"type": "object", "properties": map[string]interface{}{"x": map[string]interface{}{"type": "number"}, "y": map[string]interface{}{"type": "number"}}},
|
|
"to": map[string]interface{}{"type": "object", "properties": map[string]interface{}{"x": map[string]interface{}{"type": "number"}, "y": map[string]interface{}{"type": "number"}}},
|
|
},
|
|
"required": []string{"action"},
|
|
},
|
|
},
|
|
},
|
|
"required": []string{"actions"},
|
|
},
|
|
},
|
|
}
|
|
|
|
// MCPHandler handles MCP HTTP transport (JSON-RPC 2.0 over POST).
|
|
func (s *Service) MCPHandler(c *gin.Context) {
|
|
var req jsonRPCRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, jsonRPCResponse{
|
|
JSONRPC: "2.0",
|
|
ID: req.ID,
|
|
Error: &jsonRPCError{Code: -32700, Message: "parse error"},
|
|
})
|
|
return
|
|
}
|
|
|
|
if req.JSONRPC != "2.0" {
|
|
c.JSON(http.StatusOK, jsonRPCResponse{
|
|
JSONRPC: "2.0",
|
|
ID: req.ID,
|
|
Error: &jsonRPCError{Code: -32600, Message: "invalid request: jsonrpc must be 2.0"},
|
|
})
|
|
return
|
|
}
|
|
|
|
var resp jsonRPCResponse
|
|
switch req.Method {
|
|
case "initialize":
|
|
resp = s.mcpInitialize(req)
|
|
case "tools/list":
|
|
resp = s.mcpToolsList(req)
|
|
case "tools/call":
|
|
resp = s.mcpToolsCall(req, c)
|
|
case "ping":
|
|
resp = jsonRPCResponse{JSONRPC: "2.0", ID: req.ID, Result: map[string]interface{}{}}
|
|
default:
|
|
resp = jsonRPCResponse{
|
|
JSONRPC: "2.0",
|
|
ID: req.ID,
|
|
Error: &jsonRPCError{Code: -32601, Message: fmt.Sprintf("method not found: %s", req.Method)},
|
|
}
|
|
}
|
|
|
|
c.JSON(http.StatusOK, resp)
|
|
}
|
|
|
|
func (s *Service) mcpInitialize(req jsonRPCRequest) jsonRPCResponse {
|
|
return jsonRPCResponse{
|
|
JSONRPC: "2.0",
|
|
ID: req.ID,
|
|
Result: map[string]interface{}{
|
|
"protocolVersion": "2024-11-05",
|
|
"capabilities": map[string]interface{}{
|
|
"tools": map[string]interface{}{},
|
|
},
|
|
"serverInfo": map[string]interface{}{
|
|
"name": "nanokvm",
|
|
"version": "1.0.0",
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
func (s *Service) mcpToolsList(req jsonRPCRequest) jsonRPCResponse {
|
|
return jsonRPCResponse{
|
|
JSONRPC: "2.0",
|
|
ID: req.ID,
|
|
Result: map[string]interface{}{
|
|
"tools": mcpToolDefinitions,
|
|
},
|
|
}
|
|
}
|
|
|
|
func (s *Service) mcpToolsCall(req jsonRPCRequest, c *gin.Context) jsonRPCResponse {
|
|
var params struct {
|
|
Name string `json:"name"`
|
|
Arguments json.RawMessage `json:"arguments"`
|
|
}
|
|
if err := json.Unmarshal(req.Params, ¶ms); err != nil {
|
|
return jsonRPCResponse{
|
|
JSONRPC: "2.0",
|
|
ID: req.ID,
|
|
Error: &jsonRPCError{Code: -32602, Message: "invalid params"},
|
|
}
|
|
}
|
|
|
|
switch params.Name {
|
|
case "kvm_screenshot":
|
|
return s.mcpScreenshot(req, params.Arguments, c)
|
|
case "kvm_actions":
|
|
return s.mcpActions(req, params.Arguments, c)
|
|
default:
|
|
return jsonRPCResponse{
|
|
JSONRPC: "2.0",
|
|
ID: req.ID,
|
|
Error: &jsonRPCError{Code: -32602, Message: fmt.Sprintf("unknown tool: %s", params.Name)},
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *Service) mcpScreenshot(req jsonRPCRequest, args json.RawMessage, c *gin.Context) jsonRPCResponse {
|
|
var params struct {
|
|
Width uint16 `json:"width"`
|
|
Height uint16 `json:"height"`
|
|
Quality uint16 `json:"quality"`
|
|
}
|
|
if args != nil {
|
|
_ = json.Unmarshal(args, ¶ms)
|
|
}
|
|
|
|
query := ScreenshotQuery{
|
|
Format: "base64",
|
|
Width: params.Width,
|
|
Height: params.Height,
|
|
Quality: params.Quality,
|
|
}
|
|
|
|
data, meta, err := s.captureScreenshot(query)
|
|
if err != nil {
|
|
return mcpToolError(req, err.Message)
|
|
}
|
|
|
|
b64 := base64.StdEncoding.EncodeToString(data)
|
|
s.publishMCPObservation(c, "screenshot captured", b64)
|
|
|
|
return jsonRPCResponse{
|
|
JSONRPC: "2.0",
|
|
ID: req.ID,
|
|
Result: map[string]interface{}{
|
|
"content": []map[string]interface{}{
|
|
{
|
|
"type": "text",
|
|
"text": "screenshot captured",
|
|
},
|
|
{
|
|
"type": "image",
|
|
"data": b64,
|
|
"mimeType": "image/jpeg",
|
|
},
|
|
},
|
|
"meta": map[string]interface{}{
|
|
"source_width": meta.SourceWidth,
|
|
"source_height": meta.SourceHeight,
|
|
"capture_width": meta.CaptureWidth,
|
|
"capture_height": meta.CaptureHeight,
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
func (s *Service) publishMCPObservation(c *gin.Context, text string, imageBase64 string) {
|
|
if strings.TrimSpace(imageBase64) == "" {
|
|
return
|
|
}
|
|
|
|
sessionID, session, err := s.requireActiveGatewaySession(c)
|
|
if err != nil || session == nil || session.Downstream == nil {
|
|
return
|
|
}
|
|
|
|
message := newPicoGatewayObservationMessage(sessionID, map[string]any{
|
|
"content": text,
|
|
"image_base64": imageBase64,
|
|
"mime_type": "image/jpeg",
|
|
})
|
|
if writeErr := session.writeDownstreamJSON(s.config.Get(), message); writeErr != nil {
|
|
log.Warnf("failed to deliver picoclaw observation message: %v", writeErr)
|
|
}
|
|
}
|
|
|
|
func (s *Service) mcpActions(req jsonRPCRequest, args json.RawMessage, c *gin.Context) jsonRPCResponse {
|
|
var params struct {
|
|
Actions []Action `json:"actions"`
|
|
}
|
|
if err := json.Unmarshal(args, ¶ms); err != nil {
|
|
return mcpToolError(req, "invalid actions payload")
|
|
}
|
|
if len(params.Actions) == 0 {
|
|
return mcpToolError(req, "empty actions array")
|
|
}
|
|
|
|
sessionID := c.GetHeader(sessionIDHeader)
|
|
if sessionID == "" {
|
|
sessionID = s.lock.Owner()
|
|
}
|
|
|
|
result, err := s.executeActions(sessionID, params.Actions)
|
|
if err != nil {
|
|
return mcpToolError(req, err.Message)
|
|
}
|
|
|
|
resultJSON, _ := json.Marshal(result)
|
|
return jsonRPCResponse{
|
|
JSONRPC: "2.0",
|
|
ID: req.ID,
|
|
Result: map[string]interface{}{
|
|
"content": []map[string]interface{}{
|
|
{
|
|
"type": "text",
|
|
"text": string(resultJSON),
|
|
},
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
func mcpToolError(req jsonRPCRequest, message string) jsonRPCResponse {
|
|
return jsonRPCResponse{
|
|
JSONRPC: "2.0",
|
|
ID: req.ID,
|
|
Result: map[string]interface{}{
|
|
"isError": true,
|
|
"content": []map[string]interface{}{
|
|
{
|
|
"type": "text",
|
|
"text": message,
|
|
},
|
|
},
|
|
},
|
|
}
|
|
}
|