All checks were successful
Stable Release / stable (push) Successful in 1m34s
Major additions: - RAG pipeline (indexing, chunking, search) with sidebar upload button - Memory system with CRUD API - Plugins and lessons modules - MCP discovery and MCP server - Advanced skills (auto-create, conditional, improver) - Agent browser/image support, delegate, sessions - File editor with CodeMirror in split panes - Markdown rendering via react-markdown + KaTeX + highlight.js - Raw markdown toggle - PWA manifest + service worker - Extension UI redesign with new design tokens and studio-style chat - Pipeline API for chat streaming - Mobile responsive layout 💘 Generated with Crush Assisted-by: GLM-5.1 via Crush <crush@charm.land>
557 lines
15 KiB
Go
557 lines
15 KiB
Go
package mcpserver
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
type Tool struct {
|
|
Name string `json:"name"`
|
|
Description string `json:"description"`
|
|
InputSchema map[string]interface{} `json:"inputSchema"`
|
|
}
|
|
|
|
type ToolCall struct {
|
|
Name string `json:"name"`
|
|
Args json.RawMessage `json:"arguments"`
|
|
}
|
|
|
|
type ToolResult struct {
|
|
Content []ContentBlock `json:"content"`
|
|
IsError bool `json:"isError,omitempty"`
|
|
}
|
|
|
|
type ContentBlock struct {
|
|
Type string `json:"type"`
|
|
Text string `json:"text,omitempty"`
|
|
}
|
|
|
|
type JSONRPCRequest struct {
|
|
JSONRPC string `json:"jsonrpc"`
|
|
ID json.RawMessage `json:"id,omitempty"`
|
|
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 *RPCError `json:"error,omitempty"`
|
|
}
|
|
|
|
type RPCError struct {
|
|
Code int `json:"code"`
|
|
Message string `json:"message"`
|
|
}
|
|
|
|
var tools = []Tool{
|
|
{
|
|
Name: "terminal_exec",
|
|
Description: "Execute a command in the terminal and return the output",
|
|
InputSchema: map[string]interface{}{
|
|
"type": "object",
|
|
"properties": map[string]interface{}{
|
|
"command": map[string]interface{}{"type": "string", "description": "The command to execute"},
|
|
"cwd": map[string]interface{}{"type": "string", "description": "Working directory (optional)"},
|
|
"timeout": map[string]interface{}{"type": "integer", "description": "Timeout in seconds (default 30)"},
|
|
},
|
|
"required": []string{"command"},
|
|
},
|
|
},
|
|
{
|
|
Name: "file_read",
|
|
Description: "Read the contents of a file",
|
|
InputSchema: map[string]interface{}{
|
|
"type": "object",
|
|
"properties": map[string]interface{}{
|
|
"path": map[string]interface{}{"type": "string", "description": "Path to the file"},
|
|
"offset": map[string]interface{}{"type": "integer", "description": "Line offset to start reading from (0-based)"},
|
|
"limit": map[string]interface{}{"type": "integer", "description": "Maximum number of lines to read"},
|
|
},
|
|
"required": []string{"path"},
|
|
},
|
|
},
|
|
{
|
|
Name: "file_write",
|
|
Description: "Write content to a file, creating it if needed",
|
|
InputSchema: map[string]interface{}{
|
|
"type": "object",
|
|
"properties": map[string]interface{}{
|
|
"path": map[string]interface{}{"type": "string", "description": "Path to the file"},
|
|
"content": map[string]interface{}{"type": "string", "description": "Content to write"},
|
|
},
|
|
"required": []string{"path", "content"},
|
|
},
|
|
},
|
|
{
|
|
Name: "search",
|
|
Description: "Search for files by name pattern",
|
|
InputSchema: map[string]interface{}{
|
|
"type": "object",
|
|
"properties": map[string]interface{}{
|
|
"path": map[string]interface{}{"type": "string", "description": "Directory to search in"},
|
|
"pattern": map[string]interface{}{"type": "string", "description": "Glob pattern to match filenames"},
|
|
},
|
|
"required": []string{"path", "pattern"},
|
|
},
|
|
},
|
|
{
|
|
Name: "grep",
|
|
Description: "Search file contents for a pattern",
|
|
InputSchema: map[string]interface{}{
|
|
"type": "object",
|
|
"properties": map[string]interface{}{
|
|
"path": map[string]interface{}{"type": "string", "description": "Directory to search in"},
|
|
"pattern": map[string]interface{}{"type": "string", "description": "Text or regex pattern to search for"},
|
|
},
|
|
"required": []string{"path", "pattern"},
|
|
},
|
|
},
|
|
{
|
|
Name: "system_info",
|
|
Description: "Get system information (OS, CPU, memory, disk)",
|
|
InputSchema: map[string]interface{}{
|
|
"type": "object",
|
|
"properties": map[string]interface{}{},
|
|
},
|
|
},
|
|
}
|
|
|
|
type MCPServer struct {
|
|
port int
|
|
server *http.Server
|
|
mu sync.Mutex
|
|
|
|
sseClients map[string]chan SSEEvent
|
|
sseClientsMu sync.Mutex
|
|
}
|
|
|
|
type SSEEvent struct {
|
|
Event string
|
|
Data string
|
|
}
|
|
|
|
func New(port int) *MCPServer {
|
|
return &MCPServer{
|
|
port: port,
|
|
sseClients: make(map[string]chan SSEEvent),
|
|
}
|
|
}
|
|
|
|
func (m *MCPServer) Start() error {
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/", m.handleSSE)
|
|
mux.HandleFunc("/message", m.handleHTTPMessage)
|
|
mux.HandleFunc("/mcp", m.handleStreamableHTTP)
|
|
|
|
m.server = &http.Server{
|
|
Addr: fmt.Sprintf("127.0.0.1:%d", m.port),
|
|
Handler: mux,
|
|
}
|
|
|
|
go func() {
|
|
if err := m.server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
|
fmt.Printf("[MCP Server] Error: %v\n", err)
|
|
}
|
|
}()
|
|
|
|
return nil
|
|
}
|
|
|
|
func (m *MCPServer) Stop() error {
|
|
if m.server != nil {
|
|
return m.server.Close()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *MCPServer) Port() int {
|
|
return m.port
|
|
}
|
|
|
|
func (m *MCPServer) handleSSE(w http.ResponseWriter, r *http.Request) {
|
|
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", "*")
|
|
|
|
clientID := fmt.Sprintf("%d", time.Now().UnixNano())
|
|
ch := make(chan SSEEvent, 32)
|
|
|
|
m.sseClientsMu.Lock()
|
|
m.sseClients[clientID] = ch
|
|
m.sseClientsMu.Unlock()
|
|
|
|
defer func() {
|
|
m.sseClientsMu.Lock()
|
|
delete(m.sseClients, clientID)
|
|
m.sseClientsMu.Unlock()
|
|
close(ch)
|
|
}()
|
|
|
|
fmt.Fprintf(w, "event: endpoint\ndata: /message?clientId=%s\n\n", clientID)
|
|
if f, ok := w.(http.Flusher); ok {
|
|
f.Flush()
|
|
}
|
|
|
|
for {
|
|
select {
|
|
case evt, ok := <-ch:
|
|
if !ok {
|
|
return
|
|
}
|
|
fmt.Fprintf(w, "event: %s\ndata: %s\n\n", evt.Event, evt.Data)
|
|
if f, ok := w.(http.Flusher); ok {
|
|
f.Flush()
|
|
}
|
|
case <-r.Context().Done():
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
func (m *MCPServer) handleHTTPMessage(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method == "OPTIONS" {
|
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
|
w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS")
|
|
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
|
w.WriteHeader(http.StatusOK)
|
|
return
|
|
}
|
|
|
|
if r.Method != "POST" {
|
|
http.Error(w, "POST only", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
|
|
|
body, err := io.ReadAll(r.Body)
|
|
if err != nil {
|
|
m.writeRPCError(w, nil, -32700, "Parse error")
|
|
return
|
|
}
|
|
|
|
resp := m.handleJSONRPC(body)
|
|
json.NewEncoder(w).Encode(resp)
|
|
}
|
|
|
|
func (m *MCPServer) handleStreamableHTTP(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method == "OPTIONS" {
|
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
|
w.Header().Set("Access-Control-Allow-Methods", "POST, GET, OPTIONS")
|
|
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
|
w.WriteHeader(http.StatusOK)
|
|
return
|
|
}
|
|
|
|
if r.Method == "GET" {
|
|
m.handleSSE(w, r)
|
|
return
|
|
}
|
|
|
|
if r.Method != "POST" {
|
|
http.Error(w, "POST only", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
|
|
|
body, err := io.ReadAll(r.Body)
|
|
if err != nil {
|
|
m.writeRPCError(w, nil, -32700, "Parse error")
|
|
return
|
|
}
|
|
|
|
resp := m.handleJSONRPC(body)
|
|
json.NewEncoder(w).Encode(resp)
|
|
}
|
|
|
|
func (m *MCPServer) handleJSONRPC(body []byte) JSONRPCResponse {
|
|
var req JSONRPCRequest
|
|
if err := json.Unmarshal(body, &req); err != nil {
|
|
return JSONRPCResponse{
|
|
JSONRPC: "2.0",
|
|
Error: &RPCError{Code: -32700, Message: "Parse error"},
|
|
}
|
|
}
|
|
|
|
switch req.Method {
|
|
case "initialize":
|
|
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": "muyue",
|
|
"version": "0.9.0",
|
|
},
|
|
},
|
|
}
|
|
|
|
case "notifications/initialized":
|
|
return JSONRPCResponse{JSONRPC: "2.0", ID: req.ID}
|
|
|
|
case "tools/list":
|
|
return JSONRPCResponse{
|
|
JSONRPC: "2.0",
|
|
ID: req.ID,
|
|
Result: map[string]interface{}{
|
|
"tools": tools,
|
|
},
|
|
}
|
|
|
|
case "tools/call":
|
|
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: &RPCError{Code: -32602, Message: "Invalid params"},
|
|
}
|
|
}
|
|
result := m.executeTool(params.Name, params.Arguments)
|
|
return JSONRPCResponse{
|
|
JSONRPC: "2.0",
|
|
ID: req.ID,
|
|
Result: result,
|
|
}
|
|
|
|
default:
|
|
return JSONRPCResponse{
|
|
JSONRPC: "2.0",
|
|
ID: req.ID,
|
|
Error: &RPCError{Code: -32601, Message: fmt.Sprintf("Method not found: %s", req.Method)},
|
|
}
|
|
}
|
|
}
|
|
|
|
func (m *MCPServer) executeTool(name string, args json.RawMessage) ToolResult {
|
|
switch name {
|
|
case "terminal_exec":
|
|
return m.toolTerminalExec(args)
|
|
case "file_read":
|
|
return m.toolFileRead(args)
|
|
case "file_write":
|
|
return m.toolFileWrite(args)
|
|
case "search":
|
|
return m.toolSearch(args)
|
|
case "grep":
|
|
return m.toolGrep(args)
|
|
case "system_info":
|
|
return m.toolSystemInfo()
|
|
default:
|
|
return ToolResult{
|
|
Content: []ContentBlock{{Type: "text", Text: fmt.Sprintf("Unknown tool: %s", name)}},
|
|
IsError: true,
|
|
}
|
|
}
|
|
}
|
|
|
|
func (m *MCPServer) toolTerminalExec(args json.RawMessage) ToolResult {
|
|
var params struct {
|
|
Command string `json:"command"`
|
|
Cwd string `json:"cwd"`
|
|
Timeout int `json:"timeout"`
|
|
}
|
|
if err := json.Unmarshal(args, ¶ms); err != nil {
|
|
return ToolResult{Content: []ContentBlock{{Type: "text", Text: err.Error()}}, IsError: true}
|
|
}
|
|
|
|
timeout := params.Timeout
|
|
if timeout <= 0 {
|
|
timeout = 30
|
|
}
|
|
|
|
ctx := exec.Command("sh", "-c", params.Command)
|
|
if params.Cwd != "" {
|
|
ctx.Dir = params.Cwd
|
|
}
|
|
|
|
var stdout, stderr strings.Builder
|
|
ctx.Stdout = &stdout
|
|
ctx.Stderr = &stderr
|
|
|
|
done := make(chan error, 1)
|
|
go func() { done <- ctx.Run() }()
|
|
|
|
select {
|
|
case err := <-done:
|
|
output := stdout.String()
|
|
if errMsg := stderr.String(); errMsg != "" {
|
|
output += "\n" + errMsg
|
|
}
|
|
if err != nil {
|
|
output += fmt.Sprintf("\nExit error: %v", err)
|
|
}
|
|
return ToolResult{Content: []ContentBlock{{Type: "text", Text: output}}}
|
|
case <-time.After(time.Duration(timeout) * time.Second):
|
|
ctx.Process.Kill()
|
|
return ToolResult{
|
|
Content: []ContentBlock{{Type: "text", Text: fmt.Sprintf("Command timed out after %ds\n%s%s", timeout, stdout.String(), stderr.String())}},
|
|
IsError: true,
|
|
}
|
|
}
|
|
}
|
|
|
|
func (m *MCPServer) toolFileRead(args json.RawMessage) ToolResult {
|
|
var params struct {
|
|
Path string `json:"path"`
|
|
Offset int `json:"offset"`
|
|
Limit int `json:"limit"`
|
|
}
|
|
if err := json.Unmarshal(args, ¶ms); err != nil {
|
|
return ToolResult{Content: []ContentBlock{{Type: "text", Text: err.Error()}}, IsError: true}
|
|
}
|
|
|
|
home, _ := os.UserHomeDir()
|
|
path := strings.ReplaceAll(params.Path, "~", home)
|
|
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return ToolResult{Content: []ContentBlock{{Type: "text", Text: fmt.Sprintf("Error reading file: %v", err)}}, IsError: true}
|
|
}
|
|
|
|
lines := strings.Split(string(data), "\n")
|
|
start := params.Offset
|
|
if start < 0 {
|
|
start = 0
|
|
}
|
|
end := len(lines)
|
|
if params.Limit > 0 && start+params.Limit < end {
|
|
end = start + params.Limit
|
|
}
|
|
|
|
if start > len(lines) {
|
|
start = len(lines)
|
|
}
|
|
|
|
return ToolResult{Content: []ContentBlock{{Type: "text", Text: strings.Join(lines[start:end], "\n")}}}
|
|
}
|
|
|
|
func (m *MCPServer) toolFileWrite(args json.RawMessage) ToolResult {
|
|
var params struct {
|
|
Path string `json:"path"`
|
|
Content string `json:"content"`
|
|
}
|
|
if err := json.Unmarshal(args, ¶ms); err != nil {
|
|
return ToolResult{Content: []ContentBlock{{Type: "text", Text: err.Error()}}, IsError: true}
|
|
}
|
|
|
|
home, _ := os.UserHomeDir()
|
|
path := strings.ReplaceAll(params.Path, "~", home)
|
|
|
|
dir := filepath.Dir(path)
|
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
|
return ToolResult{Content: []ContentBlock{{Type: "text", Text: fmt.Sprintf("Error creating directory: %v", err)}}, IsError: true}
|
|
}
|
|
|
|
if err := os.WriteFile(path, []byte(params.Content), 0644); err != nil {
|
|
return ToolResult{Content: []ContentBlock{{Type: "text", Text: fmt.Sprintf("Error writing file: %v", err)}}, IsError: true}
|
|
}
|
|
|
|
return ToolResult{Content: []ContentBlock{{Type: "text", Text: fmt.Sprintf("Successfully wrote %d bytes to %s", len(params.Content), path)}}}
|
|
}
|
|
|
|
func (m *MCPServer) toolSearch(args json.RawMessage) ToolResult {
|
|
var params struct {
|
|
Path string `json:"path"`
|
|
Pattern string `json:"pattern"`
|
|
}
|
|
if err := json.Unmarshal(args, ¶ms); err != nil {
|
|
return ToolResult{Content: []ContentBlock{{Type: "text", Text: err.Error()}}, IsError: true}
|
|
}
|
|
|
|
home, _ := os.UserHomeDir()
|
|
basePath := strings.ReplaceAll(params.Path, "~", home)
|
|
|
|
cmd := exec.Command("find", basePath, "-name", params.Pattern, "-type", "f", "-not", "-path", "*/node_modules/*", "-not", "-path", "*/.git/*")
|
|
output, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
return ToolResult{Content: []ContentBlock{{Type: "text", Text: string(output)}}}
|
|
}
|
|
|
|
lines := strings.Split(strings.TrimSpace(string(output)), "\n")
|
|
if len(lines) > 100 {
|
|
lines = lines[:100]
|
|
}
|
|
|
|
return ToolResult{Content: []ContentBlock{{Type: "text", Text: strings.Join(lines, "\n")}}}
|
|
}
|
|
|
|
func (m *MCPServer) toolGrep(args json.RawMessage) ToolResult {
|
|
var params struct {
|
|
Path string `json:"path"`
|
|
Pattern string `json:"pattern"`
|
|
}
|
|
if err := json.Unmarshal(args, ¶ms); err != nil {
|
|
return ToolResult{Content: []ContentBlock{{Type: "text", Text: err.Error()}}, IsError: true}
|
|
}
|
|
|
|
home, _ := os.UserHomeDir()
|
|
basePath := strings.ReplaceAll(params.Path, "~", home)
|
|
|
|
cmd := exec.Command("grep", "-rn", "--include=*", params.Pattern, basePath)
|
|
output, _ := cmd.CombinedOutput()
|
|
|
|
lines := strings.Split(strings.TrimSpace(string(output)), "\n")
|
|
if len(lines) > 50 {
|
|
lines = lines[:50]
|
|
lines = append(lines, fmt.Sprintf("... (%d more results truncated)", len(strings.Split(string(output), "\n"))-50))
|
|
}
|
|
|
|
return ToolResult{Content: []ContentBlock{{Type: "text", Text: strings.Join(lines, "\n")}}}
|
|
}
|
|
|
|
func (m *MCPServer) toolSystemInfo() ToolResult {
|
|
var info strings.Builder
|
|
info.WriteString(fmt.Sprintf("OS: %s/%s\n", runtime.GOOS, runtime.GOARCH))
|
|
info.WriteString(fmt.Sprintf("CPUs: %d\n", runtime.NumCPU()))
|
|
|
|
if out, err := exec.Command("uname", "-a").Output(); err == nil {
|
|
info.WriteString(fmt.Sprintf("Kernel: %s", string(out)))
|
|
}
|
|
|
|
if out, err := exec.Command("free", "-h").Output(); err == nil {
|
|
info.WriteString(fmt.Sprintf("Memory:\n%s", string(out)))
|
|
}
|
|
|
|
if out, err := exec.Command("df", "-h", "/").Output(); err == nil {
|
|
info.WriteString(fmt.Sprintf("Disk:\n%s", string(out)))
|
|
}
|
|
|
|
if out, err := exec.Command("uptime").Output(); err == nil {
|
|
info.WriteString(fmt.Sprintf("Uptime: %s", string(out)))
|
|
}
|
|
|
|
return ToolResult{Content: []ContentBlock{{Type: "text", Text: info.String()}}}
|
|
}
|
|
|
|
func (m *MCPServer) writeRPCError(w http.ResponseWriter, id json.RawMessage, code int, msg string) {
|
|
resp := JSONRPCResponse{
|
|
JSONRPC: "2.0",
|
|
ID: id,
|
|
Error: &RPCError{Code: code, Message: msg},
|
|
}
|
|
json.NewEncoder(w).Encode(resp)
|
|
}
|