feat: initial commit of vps-commander release v1.0.0
This commit is contained in:
@@ -0,0 +1,382 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/wjyhk/vps-commander/internal/auth"
|
||||
)
|
||||
|
||||
// MCP Protocol / JSON-RPC 2.0 Types
|
||||
type JSONRPCRequest struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
ID any `json:"id,omitempty"`
|
||||
Method string `json:"method"`
|
||||
Params json.RawMessage `json:"params,omitempty"`
|
||||
}
|
||||
|
||||
type JSONRPCResponse struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
ID any `json:"id,omitempty"`
|
||||
Result any `json:"result,omitempty"`
|
||||
Error *JSONRPCError `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type JSONRPCError struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data any `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
// Tool Definition Structures
|
||||
type Tool struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
InputSchema InputSchema `json:"inputSchema"`
|
||||
}
|
||||
|
||||
type InputSchema struct {
|
||||
Type string `json:"type"`
|
||||
Properties map[string]PropertyDef `json:"properties"`
|
||||
Required []string `json:"required,omitempty"`
|
||||
}
|
||||
|
||||
type PropertyDef struct {
|
||||
Type string `json:"type"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
type CallToolParams struct {
|
||||
Name string `json:"name"`
|
||||
Arguments json.RawMessage `json:"arguments"`
|
||||
}
|
||||
|
||||
type TextContent struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
type CallToolResult struct {
|
||||
Content []TextContent `json:"content"`
|
||||
IsError bool `json:"isError,omitempty"`
|
||||
}
|
||||
|
||||
// Backend interface for invoking actions
|
||||
type ActionBackend interface {
|
||||
ListDevices(ctx context.Context) (any, error)
|
||||
ExecCommand(ctx context.Context, clientIP, device, command, workdir string, timeout int) (any, error)
|
||||
ReadFile(ctx context.Context, clientIP, device, path string, offset, limit int64) (any, error)
|
||||
WriteFile(ctx context.Context, clientIP, device, path, content string) (any, error)
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
Backend ActionBackend
|
||||
Auth *auth.Manager
|
||||
|
||||
mu sync.RWMutex
|
||||
sessions map[string]chan []byte
|
||||
}
|
||||
|
||||
func NewServer(backend ActionBackend, authMgr *auth.Manager) *Server {
|
||||
return &Server{
|
||||
Backend: backend,
|
||||
Auth: authMgr,
|
||||
sessions: make(map[string]chan []byte),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) newSessionID() string {
|
||||
b := make([]byte, 16)
|
||||
_, _ = rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
// SSE Handler: GET /mcp/sse
|
||||
func (s *Server) HandleSSE(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
http.Error(w, "streaming unsupported", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
sessionID := s.newSessionID()
|
||||
msgCh := make(chan []byte, 64)
|
||||
|
||||
s.mu.Lock()
|
||||
s.sessions[sessionID] = msgCh
|
||||
s.mu.Unlock()
|
||||
|
||||
defer func() {
|
||||
s.mu.Lock()
|
||||
delete(s.sessions, sessionID)
|
||||
close(msgCh)
|
||||
s.mu.Unlock()
|
||||
}()
|
||||
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
|
||||
// Endpoint event telling client where to send POST messages
|
||||
endpointURI := fmt.Sprintf("/mcp/message?sessionId=%s", sessionID)
|
||||
fmt.Fprintf(w, "event: endpoint\ndata: %s\n\n", endpointURI)
|
||||
flusher.Flush()
|
||||
|
||||
notify := r.Context().Done()
|
||||
ticker := time.NewTicker(15 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-notify:
|
||||
return
|
||||
case msg, ok := <-msgCh:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(w, "event: message\ndata: %s\n\n", string(msg))
|
||||
flusher.Flush()
|
||||
case <-ticker.C:
|
||||
fmt.Fprintf(w, ": keepalive\n\n")
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Message Handler: POST /mcp/message
|
||||
func (s *Server) HandleMessage(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
sessionID := r.URL.Query().Get("sessionId")
|
||||
s.mu.RLock()
|
||||
ch, exists := s.sessions[sessionID]
|
||||
s.mu.RUnlock()
|
||||
|
||||
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1024*1024))
|
||||
if err != nil {
|
||||
http.Error(w, "read error", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var req JSONRPCRequest
|
||||
if err := json.Unmarshal(body, &req); err != nil {
|
||||
http.Error(w, "invalid json-rpc", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
resp := s.handleRequest(r.Context(), r, req)
|
||||
|
||||
// If session exists, push response via SSE; otherwise reply directly via HTTP
|
||||
respBytes, _ := json.Marshal(resp)
|
||||
if exists && ch != nil {
|
||||
select {
|
||||
case ch <- respBytes:
|
||||
default:
|
||||
}
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
_, _ = w.Write([]byte("accepted"))
|
||||
} else {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write(respBytes)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleRequest(ctx context.Context, r *http.Request, req JSONRPCRequest) JSONRPCResponse {
|
||||
switch req.Method {
|
||||
case "initialize":
|
||||
return JSONRPCResponse{
|
||||
JSONRPC: "2.0",
|
||||
ID: req.ID,
|
||||
Result: map[string]any{
|
||||
"protocolVersion": "2024-11-05",
|
||||
"serverInfo": map[string]string{
|
||||
"name": "vps-commander",
|
||||
"version": "1.2.0",
|
||||
},
|
||||
"capabilities": map[string]any{
|
||||
"tools": map[string]bool{"listChanged": false},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
case "notifications/initialized":
|
||||
return JSONRPCResponse{JSONRPC: "2.0"}
|
||||
|
||||
case "ping":
|
||||
return JSONRPCResponse{JSONRPC: "2.0", ID: req.ID, Result: map[string]string{}}
|
||||
|
||||
case "tools/list":
|
||||
tools := []Tool{
|
||||
{
|
||||
Name: "list_devices",
|
||||
Description: "列出当前所有已注册的 VPS 节点及其在线状态与系统架构",
|
||||
InputSchema: InputSchema{
|
||||
Type: "object",
|
||||
Properties: map[string]PropertyDef{},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "exec_command",
|
||||
Description: "在指定受控节点(默认本机 local)执行任意 Shell 命令并返回输出和退出码",
|
||||
InputSchema: InputSchema{
|
||||
Type: "object",
|
||||
Properties: map[string]PropertyDef{
|
||||
"device": {Type: "string", Description: "目标设备节点名称/别名(缺省则在当前 Hub 所在节点执行)"},
|
||||
"command": {Type: "string", Description: "需要执行的 Shell 命令"},
|
||||
"workdir": {Type: "string", Description: "可选的工作目录绝对路径"},
|
||||
"timeout": {Type: "integer", Description: "超时时间(秒,默认 30,最大 300)"},
|
||||
},
|
||||
Required: []string{"command"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "read_file",
|
||||
Description: "从指定受控节点读取文件内容(支持偏移与字节分页)",
|
||||
InputSchema: InputSchema{
|
||||
Type: "object",
|
||||
Properties: map[string]PropertyDef{
|
||||
"device": {Type: "string", Description: "目标设备节点名称/别名(缺省为 local)"},
|
||||
"path": {Type: "string", Description: "文件的绝对路径"},
|
||||
"offset": {Type: "integer", Description: "读取的起始字节偏移量(默认 0)"},
|
||||
"limit": {Type: "integer", Description: "读取的最大字节数(默认 262144,最大 1048576)"},
|
||||
},
|
||||
Required: []string{"path"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "write_file",
|
||||
Description: "向指定受控节点创建或覆写文件",
|
||||
InputSchema: InputSchema{
|
||||
Type: "object",
|
||||
Properties: map[string]PropertyDef{
|
||||
"device": {Type: "string", Description: "目标设备节点名称/别名(缺省为 local)"},
|
||||
"path": {Type: "string", Description: "文件的绝对路径"},
|
||||
"content": {Type: "string", Description: "要写入的文件文本内容"},
|
||||
},
|
||||
Required: []string{"path", "content"},
|
||||
},
|
||||
},
|
||||
}
|
||||
return JSONRPCResponse{
|
||||
JSONRPC: "2.0",
|
||||
ID: req.ID,
|
||||
Result: map[string]any{"tools": tools},
|
||||
}
|
||||
|
||||
case "tools/call":
|
||||
var params CallToolParams
|
||||
if err := json.Unmarshal(req.Params, ¶ms); err != nil {
|
||||
return JSONRPCResponse{
|
||||
JSONRPC: "2.0",
|
||||
ID: req.ID,
|
||||
Error: &JSONRPCError{Code: -32602, Message: "invalid tool params"},
|
||||
}
|
||||
}
|
||||
|
||||
clientIP := r.RemoteAddr
|
||||
res, isErr := s.callTool(ctx, clientIP, params)
|
||||
resJSON, _ := json.MarshalIndent(res, "", " ")
|
||||
|
||||
return JSONRPCResponse{
|
||||
JSONRPC: "2.0",
|
||||
ID: req.ID,
|
||||
Result: CallToolResult{
|
||||
Content: []TextContent{
|
||||
{Type: "text", Text: string(resJSON)},
|
||||
},
|
||||
IsError: isErr,
|
||||
},
|
||||
}
|
||||
|
||||
default:
|
||||
return JSONRPCResponse{
|
||||
JSONRPC: "2.0",
|
||||
ID: req.ID,
|
||||
Error: &JSONRPCError{Code: -32601, Message: fmt.Sprintf("method not found: %s", req.Method)},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) callTool(ctx context.Context, clientIP string, params CallToolParams) (any, bool) {
|
||||
switch params.Name {
|
||||
case "list_devices":
|
||||
devs, err := s.Backend.ListDevices(ctx)
|
||||
if err != nil {
|
||||
return map[string]string{"error": err.Error()}, true
|
||||
}
|
||||
return devs, false
|
||||
|
||||
case "exec_command":
|
||||
var args struct {
|
||||
Device string `json:"device"`
|
||||
Command string `json:"command"`
|
||||
Workdir string `json:"workdir"`
|
||||
Timeout int `json:"timeout"`
|
||||
}
|
||||
_ = json.Unmarshal(params.Arguments, &args)
|
||||
if args.Command == "" {
|
||||
return map[string]string{"error": "command is required"}, true
|
||||
}
|
||||
res, err := s.Backend.ExecCommand(ctx, clientIP, args.Device, args.Command, args.Workdir, args.Timeout)
|
||||
if err != nil {
|
||||
return map[string]string{"error": err.Error()}, true
|
||||
}
|
||||
return res, false
|
||||
|
||||
case "read_file":
|
||||
var args struct {
|
||||
Device string `json:"device"`
|
||||
Path string `json:"path"`
|
||||
Offset int64 `json:"offset"`
|
||||
Limit int64 `json:"limit"`
|
||||
}
|
||||
_ = json.Unmarshal(params.Arguments, &args)
|
||||
if args.Path == "" {
|
||||
return map[string]string{"error": "path is required"}, true
|
||||
}
|
||||
res, err := s.Backend.ReadFile(ctx, clientIP, args.Device, args.Path, args.Offset, args.Limit)
|
||||
if err != nil {
|
||||
return map[string]string{"error": err.Error()}, true
|
||||
}
|
||||
return res, false
|
||||
|
||||
case "write_file":
|
||||
var args struct {
|
||||
Device string `json:"device"`
|
||||
Path string `json:"path"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
_ = json.Unmarshal(params.Arguments, &args)
|
||||
if args.Path == "" {
|
||||
return map[string]string{"error": "path is required"}, true
|
||||
}
|
||||
res, err := s.Backend.WriteFile(ctx, clientIP, args.Device, args.Path, args.Content)
|
||||
if err != nil {
|
||||
return map[string]string{"error": err.Error()}, true
|
||||
}
|
||||
return res, false
|
||||
|
||||
default:
|
||||
return map[string]string{"error": "unknown tool: " + params.Name}, true
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) HandleDirectRequest(ctx context.Context, req JSONRPCRequest) JSONRPCResponse {
|
||||
return s.handleRequest(ctx, &http.Request{RemoteAddr: "stdio"}, req)
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type mockBackend struct{}
|
||||
|
||||
func (m *mockBackend) ListDevices(ctx context.Context) (any, error) {
|
||||
return []map[string]any{{"name": "local", "status": "online"}}, nil
|
||||
}
|
||||
func (m *mockBackend) ExecCommand(ctx context.Context, clientIP, device, command, workdir string, timeout int) (any, error) {
|
||||
return map[string]any{"device": device, "exit_code": 0, "stdout": "ok"}, nil
|
||||
}
|
||||
func (m *mockBackend) ReadFile(ctx context.Context, clientIP, device, path string, offset, limit int64) (any, error) {
|
||||
return map[string]any{"device": device, "path": path, "content": "hello"}, nil
|
||||
}
|
||||
func (m *mockBackend) WriteFile(ctx context.Context, clientIP, device, path, content string) (any, error) {
|
||||
return map[string]any{"device": device, "path": path, "bytes": len(content)}, nil
|
||||
}
|
||||
|
||||
func TestMCPInitializeAndListTools(t *testing.T) {
|
||||
srv := NewServer(&mockBackend{}, nil)
|
||||
|
||||
// Test Initialize
|
||||
initReq := JSONRPCRequest{
|
||||
JSONRPC: "2.0",
|
||||
ID: 1,
|
||||
Method: "initialize",
|
||||
}
|
||||
body, _ := json.Marshal(initReq)
|
||||
req := httptest.NewRequest("POST", "/mcp/message", bytes.NewReader(body))
|
||||
w := httptest.NewRecorder()
|
||||
srv.HandleMessage(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
var resp JSONRPCResponse
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp.Error != nil {
|
||||
t.Fatalf("unexpected error: %v", resp.Error)
|
||||
}
|
||||
|
||||
// Test tools/list
|
||||
listReq := JSONRPCRequest{
|
||||
JSONRPC: "2.0",
|
||||
ID: 2,
|
||||
Method: "tools/list",
|
||||
}
|
||||
body, _ = json.Marshal(listReq)
|
||||
req = httptest.NewRequest("POST", "/mcp/message", bytes.NewReader(body))
|
||||
w = httptest.NewRecorder()
|
||||
srv.HandleMessage(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
var listResp JSONRPCResponse
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &listResp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resMap, ok := listResp.Result.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected map result")
|
||||
}
|
||||
tools, ok := resMap["tools"].([]any)
|
||||
if !ok || len(tools) != 4 {
|
||||
t.Fatalf("expected 4 tools, got %d", len(tools))
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPCallTool(t *testing.T) {
|
||||
srv := NewServer(&mockBackend{}, nil)
|
||||
|
||||
callReq := JSONRPCRequest{
|
||||
JSONRPC: "2.0",
|
||||
ID: 3,
|
||||
Method: "tools/call",
|
||||
Params: json.RawMessage(`{"name":"exec_command","arguments":{"command":"echo test"}}`),
|
||||
}
|
||||
body, _ := json.Marshal(callReq)
|
||||
req := httptest.NewRequest("POST", "/mcp/message", bytes.NewReader(body))
|
||||
w := httptest.NewRecorder()
|
||||
srv.HandleMessage(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
var resp JSONRPCResponse
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp.Error != nil {
|
||||
t.Fatalf("unexpected error: %v", resp.Error)
|
||||
}
|
||||
contentStr := string(w.Body.Bytes())
|
||||
if !strings.Contains(contentStr, "stdout") {
|
||||
t.Fatalf("expected stdout in result, got %s", contentStr)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// HandleOAuthMetadata serves /.well-known/oauth-authorization-server
|
||||
func (s *Server) HandleOAuthMetadata(w http.ResponseWriter, r *http.Request) {
|
||||
scheme := "https"
|
||||
host := r.Host
|
||||
meta := map[string]any{
|
||||
"issuer": fmt.Sprintf("%s://%s", scheme, host),
|
||||
"authorization_endpoint": fmt.Sprintf("%s://%s/oauth/authorize", scheme, host),
|
||||
"token_endpoint": fmt.Sprintf("%s://%s/oauth/token", scheme, host),
|
||||
"response_types_supported": []string{"code"},
|
||||
"grant_types_supported": []string{"authorization_code"},
|
||||
"code_challenge_methods_supported": []string{"S256", "plain"},
|
||||
"token_endpoint_auth_methods_supported": []string{"client_secret_post", "client_secret_basic", "none"},
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
_ = json.NewEncoder(w).Encode(meta)
|
||||
}
|
||||
|
||||
// HandleAuthorize handles the user/client authorization step
|
||||
func (s *Server) HandleAuthorize(w http.ResponseWriter, r *http.Request) {
|
||||
redirectURI := r.URL.Query().Get("redirect_uri")
|
||||
state := r.URL.Query().Get("state")
|
||||
|
||||
if redirectURI == "" {
|
||||
http.Error(w, "missing redirect_uri", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
code := "vps-commander-auth-code"
|
||||
target := fmt.Sprintf("%s?code=%s&state=%s", redirectURI, code, state)
|
||||
http.Redirect(w, r, target, http.StatusFound)
|
||||
}
|
||||
|
||||
// HandleToken exchanges code for bearer token
|
||||
func (s *Server) HandleToken(w http.ResponseWriter, r *http.Request) {
|
||||
token := ""
|
||||
if s.Auth != nil {
|
||||
token = s.Auth.Token()
|
||||
}
|
||||
|
||||
resp := map[string]any{
|
||||
"access_token": token,
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 86400 * 365,
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
_ = json.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
Reference in New Issue
Block a user