From 7821bc8681ea054783f850f188f7c41293b766ae Mon Sep 17 00:00:00 2001 From: MickaelK Date: Tue, 1 Apr 2025 10:36:28 +1100 Subject: [PATCH] feature (mcp): mcp server --- server/plugin/index.go | 1 + .../plugin/plg_handler_mcp/config/config.go | 33 ++ server/plugin/plg_handler_mcp/handler.go | 213 +++++++++++ server/plugin/plg_handler_mcp/handler_auth.go | 116 ++++++ .../plugin/plg_handler_mcp/handler_state.go | 39 ++ .../plugin/plg_handler_mcp/impl/completion.go | 50 +++ server/plugin/plg_handler_mcp/impl/prompts.go | 46 +++ .../plugin/plg_handler_mcp/impl/prompts_fs.go | 113 ++++++ .../plugin/plg_handler_mcp/impl/resources.go | 17 + server/plugin/plg_handler_mcp/impl/tools.go | 37 ++ .../plugin/plg_handler_mcp/impl/tools_fs.go | 340 ++++++++++++++++++ server/plugin/plg_handler_mcp/index.go | 42 +++ .../plg_handler_mcp/types/mcp_completion.go | 11 + .../plugin/plg_handler_mcp/types/mcp_init.go | 18 + .../plg_handler_mcp/types/mcp_notification.go | 18 + .../plg_handler_mcp/types/mcp_prompts.go | 28 ++ .../plg_handler_mcp/types/mcp_resources.go | 33 ++ .../plugin/plg_handler_mcp/types/mcp_tools.go | 19 + .../plugin/plg_handler_mcp/types/resources.go | 26 ++ server/plugin/plg_handler_mcp/types/rpc.go | 31 ++ .../plugin/plg_handler_mcp/types/session.go | 22 ++ server/plugin/plg_handler_mcp/utils/cors.go | 10 + .../plugin/plg_handler_mcp/utils/default.go | 14 + server/plugin/plg_handler_mcp/utils/json.go | 15 + server/plugin/plg_handler_mcp/utils/mcp.go | 25 ++ .../plugin/plg_handler_mcp/utils/response.go | 93 +++++ 26 files changed, 1410 insertions(+) create mode 100644 server/plugin/plg_handler_mcp/config/config.go create mode 100644 server/plugin/plg_handler_mcp/handler.go create mode 100644 server/plugin/plg_handler_mcp/handler_auth.go create mode 100644 server/plugin/plg_handler_mcp/handler_state.go create mode 100644 server/plugin/plg_handler_mcp/impl/completion.go create mode 100644 server/plugin/plg_handler_mcp/impl/prompts.go create mode 100644 server/plugin/plg_handler_mcp/impl/prompts_fs.go create mode 100644 server/plugin/plg_handler_mcp/impl/resources.go create mode 100644 server/plugin/plg_handler_mcp/impl/tools.go create mode 100644 server/plugin/plg_handler_mcp/impl/tools_fs.go create mode 100644 server/plugin/plg_handler_mcp/index.go create mode 100644 server/plugin/plg_handler_mcp/types/mcp_completion.go create mode 100644 server/plugin/plg_handler_mcp/types/mcp_init.go create mode 100644 server/plugin/plg_handler_mcp/types/mcp_notification.go create mode 100644 server/plugin/plg_handler_mcp/types/mcp_prompts.go create mode 100644 server/plugin/plg_handler_mcp/types/mcp_resources.go create mode 100644 server/plugin/plg_handler_mcp/types/mcp_tools.go create mode 100644 server/plugin/plg_handler_mcp/types/resources.go create mode 100644 server/plugin/plg_handler_mcp/types/rpc.go create mode 100644 server/plugin/plg_handler_mcp/types/session.go create mode 100644 server/plugin/plg_handler_mcp/utils/cors.go create mode 100644 server/plugin/plg_handler_mcp/utils/default.go create mode 100644 server/plugin/plg_handler_mcp/utils/json.go create mode 100644 server/plugin/plg_handler_mcp/utils/mcp.go create mode 100644 server/plugin/plg_handler_mcp/utils/response.go diff --git a/server/plugin/index.go b/server/plugin/index.go index 0165123b..1d3730e6 100644 --- a/server/plugin/index.go +++ b/server/plugin/index.go @@ -29,6 +29,7 @@ import ( _ "github.com/mickael-kerjean/filestash/server/plugin/plg_backend_webdav" _ "github.com/mickael-kerjean/filestash/server/plugin/plg_editor_wopi" _ "github.com/mickael-kerjean/filestash/server/plugin/plg_handler_console" + _ "github.com/mickael-kerjean/filestash/server/plugin/plg_handler_mcp" _ "github.com/mickael-kerjean/filestash/server/plugin/plg_image_ascii" _ "github.com/mickael-kerjean/filestash/server/plugin/plg_image_c" _ "github.com/mickael-kerjean/filestash/server/plugin/plg_image_transcode" diff --git a/server/plugin/plg_handler_mcp/config/config.go b/server/plugin/plg_handler_mcp/config/config.go new file mode 100644 index 00000000..44fa8c98 --- /dev/null +++ b/server/plugin/plg_handler_mcp/config/config.go @@ -0,0 +1,33 @@ +package plg_handler_mcp + +import ( + . "github.com/mickael-kerjean/filestash/server/common" +) + +var PluginEnable = func() bool { + return Config.Get("features.mcp.enable").Schema(func(f *FormElement) *FormElement { + if f == nil { + f = &FormElement{} + } + f.Name = "enable" + f.Type = "enable" + f.Target = []string{"mcp_can_edit"} + f.Description = "Enable/Disable the Model Context Protocol" + f.Default = false + return f + }).Bool() +} + +var CanEdit = func() bool { + return Config.Get("features.mcp.can_edit").Schema(func(f *FormElement) *FormElement { + if f == nil { + f = &FormElement{} + } + f.Id = "mcp_can_edit" + f.Name = "can_edit" + f.Type = "boolean" + f.Description = "Enable/Disable editing" + f.Default = false + return f + }).Bool() +} diff --git a/server/plugin/plg_handler_mcp/handler.go b/server/plugin/plg_handler_mcp/handler.go new file mode 100644 index 00000000..6e54cbbb --- /dev/null +++ b/server/plugin/plg_handler_mcp/handler.go @@ -0,0 +1,213 @@ +package plg_handler_mcp + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "time" + + . "github.com/mickael-kerjean/filestash/server/common" + "github.com/mickael-kerjean/filestash/server/model" + . "github.com/mickael-kerjean/filestash/server/plugin/plg_handler_mcp/impl" + . "github.com/mickael-kerjean/filestash/server/plugin/plg_handler_mcp/types" + . "github.com/mickael-kerjean/filestash/server/plugin/plg_handler_mcp/utils" + + "github.com/google/uuid" +) + +func (this *Server) messageHandler(w http.ResponseWriter, r *http.Request) { + sessionID := r.URL.Query().Get("sessionId") + if r.Method != http.MethodPost { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte("Invalid Request")) + return + } + + request := JSONRPCRequest{} + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte("ERR: " + err.Error())) + return + } + this.GetSession(sessionID).Chan <- request + w.WriteHeader(http.StatusNoContent) +} + +func (this *Server) sseHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + w.WriteHeader(http.StatusBadRequest) + return + } + token := this.ValidateToken(r.Header.Get("Authorization")) + if token == "" { + w.Header().Add("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + json.NewEncoder(w).Encode(JSONRPCResponse{ + JSONRPC: "2.0", + Error: &JSONRPCError{ + Code: http.StatusUnauthorized, + Message: "Missing or invalid access token", + }, + }) + return + } + + userSession := this.GetSession(uuid.New().String()) + userSession.Token = token + if b, err := getBackend(userSession.Token); err == nil { + userSession.HomeDir, _ = model.GetHome(b, "/") + userSession.CurrDir = ToString(userSession.HomeDir, "/") + } + + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + + fmt.Fprintf(w, "event: endpoint\ndata: %s?sessionId=%s\n\n", "/messages", userSession.Id) + w.(http.Flusher).Flush() + + for { + select { + case request := <-userSession.Chan: + b, err := getBackend(userSession.Token) + if err != nil { + if err == ErrNotAuthorized { + err = JSONRPCError{ + Code: ErrNotAuthorized.Status(), + Message: "You aren't authenticated", + } + } + SendError(w, request.ID, err) + break + } + userSession.Backend = b + + switch request.Method { + case "initialize": + SendMessage(w, request.ID, InitializeResult{ + ProtocolVersion: "2024-11-05", + ServerInfo: ServerInfo{ + Name: "Universal Storage Server", + Version: "1.0.0", + }, + Capabilities: Capabilities{ + Tools: map[string]interface{}{ + "listChanged": true, + }, + Resources: map[string]interface{}{}, + Prompts: map[string]interface{}{}, + }, + }) + case "resources/list": + SendMessage(w, request.ID, &CallResourcesList{ + Resources: AllResources(), + }) + case "resources/templates/list": + SendMessage(w, request.ID, &CallResourceTemplatesList{ + ResourceTemplates: AllResourceTemplates(), + }) + case "resources/read": + SendMessage(w, request.ID, &CallResourceRead{ + Contents: ExecResourceRead(request.Params), + }) + case "prompts/list": + SendMessage(w, request.ID, &CallPromptsList{ + Prompts: AllPrompts(), + }) + case "prompts/get": + if m, ok := request.Params["name"].(string); ok { + res, err := ExecPromptGet(m, request.Params, &userSession) + if err == nil { + SendMessage(w, request.ID, CallPromptGet{ + Messages: res, + Description: ExecPromptDescription(request.Params), + }) + } else { + SendError(w, request.ID, err) + } + } else { + SendError(w, request.ID, JSONRPCError{ + Code: http.StatusBadRequest, + Message: fmt.Sprintf("Unknown prompt name: %v", request.Params["name"]), + }) + } + case "tools/list": + SendMessage(w, request.ID, &CallListTools{ + Tools: AllTools(), + }) + case "tools/call": + if m, ok := request.Params["name"].(string); ok { + res, err := ExecTool(m, request.Params, &userSession) + if err == nil { + SendMessage(w, request.ID, CallTool{ + Content: []TextContent{*res}, + }) + } else { + SendError(w, request.ID, err) + } + } else { + SendError(w, request.ID, JSONRPCError{ + Code: http.StatusBadRequest, + Message: fmt.Sprintf("Unknown tool name: %v", request.Params["name"]), + }) + } + case "notifications/initialized": + SendMessage(w, request.ID, map[string]string{}) + case "completion/complete": + SendMessage(w, request.ID, CallCompletionResult{ + Completion: ExecCompletion(request.Params, &userSession), + }) + case "ping": + SendMessage(w, request.ID, map[string]string{}) + default: + if request.Method == "" && userSession.Ping.ID == request.ID { // response to ping + userSession.Ping.LastResponse = time.Now() + userSession.Ping.ID += 1 + } else { + Log.Warning("plg_handler_mcp::sse message=unknown_method method=%s requestID=%d", request.Method, request.ID) + SendError(w, request.ID, JSONRPCError{ + Code: http.StatusMethodNotAllowed, + Message: fmt.Sprintf("Unknown request: %s", request.Method), + }) + } + } + case <-r.Context().Done(): + this.RemoveSession(&userSession) + return + case <-time.After(15 * time.Second): + SendPing(w, userSession.Ping.ID) + if time.Since(userSession.Ping.LastResponse) > 60*time.Second { + SendMethod(w, userSession.Ping.ID+1, "notifications/cancelled", map[string]interface{}{ + "requestId": userSession.Ping.ID, + "reason": "Request timed out", + }) + time.Sleep(2 * time.Second) + this.RemoveSession(&userSession) + if hi, ok := w.(http.Hijacker); ok { + if conn, rw, err := hi.Hijack(); err == nil { + rw.WriteString("0\r\n\r\n") + rw.Flush() + time.Sleep(1 * time.Second) + conn.Close() + } + } + return + } + } + } +} + +func getBackend(token string) (IBackend, error) { + str, err := DecryptString(SECRET_KEY_DERIVATE_FOR_USER, token) + if err != nil { + return nil, ErrNotAuthorized + } + session := map[string]string{} + if err = json.Unmarshal([]byte(str), &session); err != nil { + return nil, err + } + return model.NewBackend(&App{ + Context: context.Background(), + }, session) +} diff --git a/server/plugin/plg_handler_mcp/handler_auth.go b/server/plugin/plg_handler_mcp/handler_auth.go new file mode 100644 index 00000000..86653de1 --- /dev/null +++ b/server/plugin/plg_handler_mcp/handler_auth.go @@ -0,0 +1,116 @@ +package plg_handler_mcp + +import ( + "encoding/json" + "fmt" + "net/http" + "time" + + . "github.com/mickael-kerjean/filestash/server/common" + . "github.com/mickael-kerjean/filestash/server/plugin/plg_handler_mcp/utils" +) + +const ( + DEFAULT_TOKEN_EXPIRY = 3600 +) + +func (this Server) WellKnownInfoHandler(w http.ResponseWriter, r *http.Request) { + WithCors(w) + if r.Method != http.MethodGet && r.Method != http.MethodOptions { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + scheme := "http" + if r.TLS != nil { + scheme = "https" + } + host := r.Host + baseURL := fmt.Sprintf("%s://%s", scheme, host) + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "issuer": baseURL, + "authorization_endpoint": fmt.Sprintf("%s/mcp/authorize", baseURL), + "token_endpoint": fmt.Sprintf("%s/mcp/token", baseURL), + "registration_endpoint": fmt.Sprintf("%s/mcp/register", baseURL), + "response_types_supported": []string{"code"}, + "grant_types_supported": []string{"authorization_code"}, + "token_endpoint_auth_methods_supported": []string{ + "none", + }, + "code_challenge_methods_supported": []string{ + "S256", + }, + }) +} + +func (this Server) AuthorizeHandler(w http.ResponseWriter, r *http.Request) { + WithCors(w) + + responseType := r.URL.Query().Get("response_type") + clientID := r.URL.Query().Get("client_id") + redirectURI := r.URL.Query().Get("redirect_uri") + + if responseType != "code" { + http.Error(w, "response_type must be 'code'", http.StatusBadRequest) + return + } else if clientID == "" { + http.Error(w, "client_id is required", http.StatusBadRequest) + return + } else if redirectURI == "" { + http.Error(w, "redirect_uri is required", http.StatusBadRequest) + return + } + + http.Redirect(w, r, fmt.Sprintf("/login?next=/api/mcp?redirect_uri=%s", redirectURI), http.StatusSeeOther) +} + +func (this Server) TokenHandler(w http.ResponseWriter, r *http.Request) { + WithCors(w) + if r.Method != http.MethodPost && r.Method != http.MethodOptions { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + if err := r.ParseForm(); err != nil { + http.Error(w, "Invalid request", http.StatusBadRequest) + return + } + if grantType := r.FormValue("grant_type"); grantType != "authorization_code" { + http.Error(w, "Invalid Grant Type", http.StatusBadRequest) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "access_token": r.FormValue("code"), + "token_type": "Bearer", + }) +} + +func (this Server) RegisterHandler(w http.ResponseWriter, r *http.Request) { + WithCors(w) + if r.Method != http.MethodPost && r.Method != http.MethodOptions { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + w.WriteHeader(http.StatusCreated) + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "client_id": "anonymous", + "client_secret": "anonymous", + "client_id_issued_at": time.Now().Unix(), + "client_secret_expires_at": 0, + "client_name": "Untrusted", + "redirect_uris": []string{}, + "grant_types": []string{"authorization_code"}, + "token_endpoint_auth_method": "client_secret_basic", + }) +} + +func (this Server) CallbackHandler(ctx *App, res http.ResponseWriter, req *http.Request) { + uri := req.URL.Query().Get("redirect_uri") + if uri == "" { + SendErrorResult(res, ErrNotValid) + return + } + http.Redirect(res, req, fmt.Sprintf(uri+"?code=%s", ctx.Authorization), http.StatusSeeOther) +} diff --git a/server/plugin/plg_handler_mcp/handler_state.go b/server/plugin/plg_handler_mcp/handler_state.go new file mode 100644 index 00000000..adf2c942 --- /dev/null +++ b/server/plugin/plg_handler_mcp/handler_state.go @@ -0,0 +1,39 @@ +package plg_handler_mcp + +import ( + "strings" + "time" + + . "github.com/mickael-kerjean/filestash/server/common" + . "github.com/mickael-kerjean/filestash/server/plugin/plg_handler_mcp/types" +) + +func (this *Server) RemoveSession(userSession *UserSession) { + this.expired.Store(Hash(userSession.Token, 16), nil) + this.sessions.Delete(userSession.Id) +} + +func (this *Server) ValidateToken(token string) string { + if hasToken := strings.HasPrefix(token, "Bearer "); hasToken == false { + return "" + } + token = strings.TrimPrefix(token, "Bearer ") + if _, ok := this.expired.Load(Hash(token, 16)); ok { + return "" + } + return token +} + +func (this *Server) GetSession(uuid string) UserSession { + ch, _ := this.sessions.LoadOrStore(uuid, UserSession{ + Id: uuid, + Chan: make(chan JSONRPCRequest), + CurrDir: "/", + HomeDir: "/", + Ping: Ping{ + ID: 0, + LastResponse: time.Now(), + }, + }) + return ch.(UserSession) +} diff --git a/server/plugin/plg_handler_mcp/impl/completion.go b/server/plugin/plg_handler_mcp/impl/completion.go new file mode 100644 index 00000000..161774fd --- /dev/null +++ b/server/plugin/plg_handler_mcp/impl/completion.go @@ -0,0 +1,50 @@ +package impl + +import ( + "path/filepath" + "strings" + + . "github.com/mickael-kerjean/filestash/server/common" + . "github.com/mickael-kerjean/filestash/server/plugin/plg_handler_mcp/types" + . "github.com/mickael-kerjean/filestash/server/plugin/plg_handler_mcp/utils" +) + +func ExecCompletion(params map[string]any, userSession *UserSession) Completion { + if path := GetArgumentString(params, "value"); path != "" && GetArgumentString(params, "name") == "path" { + fpath := filepath.Dir(path) + fname := filepath.Base(path) + if strings.HasSuffix(path, "/") { + fname = "" + } + files, err := userSession.Backend.Ls(EnforceDirectory(fpath)) + if err == nil { + values := []string{} + for _, file := range files { + val := JoinPath(fpath, file.Name()) + if file.IsDir() { + val = EnforceDirectory(val) + } + + if fname == "" && strings.HasPrefix(file.Name(), ".") == false { + values = append(values, val) + } else if fname != "" && strings.HasPrefix(file.Name(), fname) { + values = append(values, val) + } + + if len(values) >= 100 { + break + } + } + return Completion{ + Values: values, + Total: uint64(len(values)), + HasMore: false, + } + } + } + return Completion{ + Values: []string{}, + Total: 0, + HasMore: false, + } +} diff --git a/server/plugin/plg_handler_mcp/impl/prompts.go b/server/plugin/plg_handler_mcp/impl/prompts.go new file mode 100644 index 00000000..e1aaccf0 --- /dev/null +++ b/server/plugin/plg_handler_mcp/impl/prompts.go @@ -0,0 +1,46 @@ +package impl + +import ( + "net/http" + + . "github.com/mickael-kerjean/filestash/server/plugin/plg_handler_mcp/types" +) + +var listOfPrompts = map[string]PromptDefinition{} + +type PromptDefinition struct { + Prompt + ExecMessage func(params map[string]any, userSession *UserSession) ([]PromptMessage, error) + ExecDescription func(params map[string]any) string +} + +func RegisterPrompt(t PromptDefinition) { + listOfPrompts[t.Name] = t +} + +func AllPrompts() []Prompt { + t := []Prompt{} + for _, v := range listOfPrompts { + t = append(t, v.Prompt) + } + return t +} + +func ExecPromptGet(name string, params map[string]any, userSession *UserSession) ([]PromptMessage, error) { + t, ok := listOfPrompts[name] + if !ok { + return nil, &JSONRPCError{ + Code: http.StatusNotFound, + Message: "Not Found", + } + } + return t.ExecMessage(params, userSession) +} + +func ExecPromptDescription(params map[string]any) string { + n, ok := params["name"].(string) + if !ok { + return "" + } + return n +} diff --git a/server/plugin/plg_handler_mcp/impl/prompts_fs.go b/server/plugin/plg_handler_mcp/impl/prompts_fs.go new file mode 100644 index 00000000..40e116bc --- /dev/null +++ b/server/plugin/plg_handler_mcp/impl/prompts_fs.go @@ -0,0 +1,113 @@ +package impl + +import ( + . "github.com/mickael-kerjean/filestash/server/plugin/plg_handler_mcp/types" +) + +func init() { + RegisterPrompt(PromptDefinition{ + Prompt: Prompt{ + Name: "ls", + Description: "list directory contents", + Arguments: []PromptArgument{ + { + Name: "path", + Description: "path where the query is made", + Required: false, + }, + }, + }, + ExecMessage: func(params map[string]any, userSession *UserSession) ([]PromptMessage, error) { + return []PromptMessage{ + { + Role: "user", + Content: TextContent{ + Type: "text", + Text: "call ls(path)", + }, + }, + }, nil + }, + ExecDescription: func(params map[string]any) string { + return "list directory contents" + }, + }) + + RegisterPrompt(PromptDefinition{ + Prompt: Prompt{ + Name: "cat", + Description: "read a file at a specified path", + Arguments: []PromptArgument{ + { + Name: "path", + Description: "path where the query is made", + Required: true, + }, + }, + }, + ExecMessage: func(params map[string]any, userSession *UserSession) ([]PromptMessage, error) { + return []PromptMessage{ + { + Role: "user", + Content: TextContent{ + Type: "text", + Text: "call cat(path)", + }, + }, + }, nil + }, + ExecDescription: func(params map[string]any) string { + return "read a file at a specified path" + }, + }) + + RegisterPrompt(PromptDefinition{ + Prompt: Prompt{ + Name: "pwd", + Description: "print name of current/working directory", + Arguments: []PromptArgument{}, + }, + ExecMessage: func(params map[string]any, userSession *UserSession) ([]PromptMessage, error) { + return []PromptMessage{ + { + Role: "user", + Content: TextContent{ + Type: "text", + Text: "call pwd", + }, + }, + }, nil + }, + ExecDescription: func(params map[string]any) string { + return "print name of current/working directory" + }, + }) + + RegisterPrompt(PromptDefinition{ + Prompt: Prompt{ + Name: "cd", + Description: "change the working directory", + Arguments: []PromptArgument{ + { + Name: "path", + Description: "path where the query is made", + Required: false, + }, + }, + }, + ExecMessage: func(params map[string]any, userSession *UserSession) ([]PromptMessage, error) { + return []PromptMessage{ + { + Role: "user", + Content: TextContent{ + Type: "text", + Text: "call cd(path)", + }, + }, + }, nil + }, + ExecDescription: func(params map[string]any) string { + return "change the working directory" + }, + }) +} diff --git a/server/plugin/plg_handler_mcp/impl/resources.go b/server/plugin/plg_handler_mcp/impl/resources.go new file mode 100644 index 00000000..3fd2f824 --- /dev/null +++ b/server/plugin/plg_handler_mcp/impl/resources.go @@ -0,0 +1,17 @@ +package impl + +import ( + . "github.com/mickael-kerjean/filestash/server/plugin/plg_handler_mcp/types" +) + +func AllResources() []Resource { + return []Resource{} +} + +func AllResourceTemplates() []ResourceTemplate { + return []ResourceTemplate{} +} + +func ExecResourceRead(params map[string]any) []ResourceContent { + return []ResourceContent{} +} diff --git a/server/plugin/plg_handler_mcp/impl/tools.go b/server/plugin/plg_handler_mcp/impl/tools.go new file mode 100644 index 00000000..fe9a3a15 --- /dev/null +++ b/server/plugin/plg_handler_mcp/impl/tools.go @@ -0,0 +1,37 @@ +package impl + +import ( + "net/http" + + . "github.com/mickael-kerjean/filestash/server/plugin/plg_handler_mcp/types" +) + +var listOfTools = map[string]ToolDefinition{} + +type ToolDefinition struct { + Tool + Exec func(params map[string]any, userSession *UserSession) (*TextContent, error) +} + +func RegisterTool(t ToolDefinition) { + listOfTools[t.Name] = t +} + +func AllTools() []Tool { + t := []Tool{} + for _, v := range listOfTools { + t = append(t, v.Tool) + } + return t +} + +func ExecTool(name string, params map[string]any, userSession *UserSession) (*TextContent, error) { + td, ok := listOfTools[name] + if !ok { + return nil, JSONRPCError{ + Code: http.StatusNotImplemented, + Message: "Not Found", + } + } + return td.Exec(params, userSession) +} diff --git a/server/plugin/plg_handler_mcp/impl/tools_fs.go b/server/plugin/plg_handler_mcp/impl/tools_fs.go new file mode 100644 index 00000000..9ea5a686 --- /dev/null +++ b/server/plugin/plg_handler_mcp/impl/tools_fs.go @@ -0,0 +1,340 @@ +package impl + +import ( + "bytes" + "errors" + "io" + "path/filepath" + "strings" + + . "github.com/mickael-kerjean/filestash/server/common" + . "github.com/mickael-kerjean/filestash/server/plugin/plg_handler_mcp/config" + . "github.com/mickael-kerjean/filestash/server/plugin/plg_handler_mcp/types" + . "github.com/mickael-kerjean/filestash/server/plugin/plg_handler_mcp/utils" +) + +func init() { + Hooks.Register.Onload(func() { + RegisterTool(ToolDefinition{ + Tool: Tool{ + Name: "ls", + Description: "list directory contents", + InputSchema: JsonSchema(map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "path": map[string]string{ + "type": "string", + "description": "path where the query is made", + }, + }, + "required": []string{}, + }), + }, + Exec: ToolFSLs, + }) + + RegisterTool(ToolDefinition{ + Tool: Tool{ + Name: "cat", + Description: "read a file at a specified path.", + InputSchema: JsonSchema(map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "path": map[string]string{ + "type": "string", + "description": "path where the query is made", + }, + }, + "required": []string{"path"}, + }), + }, + Exec: ToolFSCat, + }) + + RegisterTool(ToolDefinition{ + Tool: Tool{ + Name: "pwd", + Description: "print name of current/working directory", + InputSchema: JsonSchema(map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{}, + "required": []string{}, + }), + }, + Exec: ToolFSPwd, + }) + + RegisterTool(ToolDefinition{ + Tool: Tool{ + Name: "cd", + Description: "change the working directory", + InputSchema: JsonSchema(map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "path": map[string]string{ + "type": "string", + "description": "path where the query is made", + }, + }, + "required": []string{"path"}, + }), + }, + Exec: ToolFSCd, + }) + + if !CanEdit() { + return + } + + RegisterTool(ToolDefinition{ + Tool: Tool{ + Name: "mv", + Description: "move (rename) files", + InputSchema: JsonSchema(map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "from": map[string]string{ + "type": "string", + "description": "origin path", + }, + "to": map[string]string{ + "type": "string", + "description": "destination path", + }, + }, + "required": []string{"from", "to"}, + }), + }, + Exec: ToolFSMv, + }) + + RegisterTool(ToolDefinition{ + Tool: Tool{ + Name: "mkdir", + Description: "make directories", + InputSchema: JsonSchema(map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "path": map[string]string{ + "type": "string", + "description": "path where the query is made", + }, + }, + "required": []string{"path"}, + }), + }, + Exec: ToolFSMkdir, + }) + + RegisterTool(ToolDefinition{ + Tool: Tool{ + Name: "touch", + Description: "create file", + InputSchema: JsonSchema(map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "path": map[string]string{ + "type": "string", + "description": "path where the query is made", + }, + }, + "required": []string{"path"}, + }), + }, + Exec: ToolFSTouch, + }) + + RegisterTool(ToolDefinition{ + Tool: Tool{ + Name: "rm", + Description: "remove files or directories", + InputSchema: JsonSchema(map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "path": map[string]string{ + "type": "string", + "description": "path where the query is made", + }, + }, + "required": []string{"path"}, + }), + }, + Exec: ToolFSRm, + }) + + RegisterTool(ToolDefinition{ + Tool: Tool{ + Name: "save", + Description: "save a file", + InputSchema: JsonSchema(map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "path": map[string]string{ + "type": "string", + "description": "path where the query is made", + }, + "content": map[string]string{ + "type": "string", + "description": "content of the file", + }, + }, + "required": []string{"path"}, + }), + }, + Exec: ToolFSSave, + }) + }) +} + +func ToolFSLs(params map[string]any, userSession *UserSession) (*TextContent, error) { + files, err := userSession.Backend.Ls(EnforceDirectory(getPath(params, userSession, "path"))) + if err != nil { + return nil, err + } + var b bytes.Buffer + for _, file := range files { + if file.IsDir() { + b.Write([]byte("[DIR] ")) + } else { + b.Write([]byte("[FILE] ")) + } + b.Write([]byte(file.Name())) + b.Write([]byte("\n")) + } + return &TextContent{ + Type: "text", + Text: b.String(), + }, nil +} + +func ToolFSCat(params map[string]any, userSession *UserSession) (*TextContent, error) { + if isArgEmpty(params, "path") { + return nil, ErrNotValid + } + r, err := userSession.Backend.Cat(getPath(params, userSession, "path")) + if err != nil { + return nil, err + } + b, err := io.ReadAll(r) + r.Close() + if err != nil { + return nil, err + } + return &TextContent{ + Type: "text", + Text: string(b), + }, nil +} + +func ToolFSPwd(params map[string]any, userSession *UserSession) (*TextContent, error) { + return &TextContent{ + Type: "text", + Text: userSession.CurrDir, + }, nil +} + +func ToolFSCd(params map[string]any, userSession *UserSession) (*TextContent, error) { + path := EnforceDirectory(getPath(params, userSession, "path")) + if _, err := userSession.Backend.Ls(path); err != nil { + return nil, errors.New("No such file or directory") + } + userSession.CurrDir = EnforceDirectory(path) + return &TextContent{ + Type: "text", + Text: userSession.CurrDir, + }, nil +} + +func ToolFSMv(params map[string]any, userSession *UserSession) (*TextContent, error) { + if isArgEmpty(params, "from") || isArgEmpty(params, "to") { + return nil, ErrNotValid + } + if err := userSession.Backend.Mv( + getPath(params, userSession, "from"), + getPath(params, userSession, "to"), + ); err != nil { + return nil, err + } + return &TextContent{ + Type: "text", + Text: "", + }, nil +} + +func ToolFSMkdir(params map[string]any, userSession *UserSession) (*TextContent, error) { + if isArgEmpty(params, "path") { + return nil, ErrNotValid + } + if err := userSession.Backend.Mkdir(EnforceDirectory(getPath(params, userSession, "path"))); err != nil { + return nil, err + } + return &TextContent{ + Type: "text", + Text: "", + }, nil +} + +func ToolFSTouch(params map[string]any, userSession *UserSession) (*TextContent, error) { + if isArgEmpty(params, "path") { + return nil, ErrNotValid + } + if err := userSession.Backend.Touch(getPath(params, userSession, "path")); err != nil { + return nil, err + } + return &TextContent{ + Type: "text", + Text: "", + }, nil +} + +func ToolFSRm(params map[string]any, userSession *UserSession) (*TextContent, error) { + if isArgEmpty(params, "path") { + return nil, ErrNotValid + } + if err := userSession.Backend.Rm(getPath(params, userSession, "path")); err != nil { + return nil, err + } + return &TextContent{ + Type: "text", + Text: "", + }, nil +} + +func ToolFSSave(params map[string]any, userSession *UserSession) (*TextContent, error) { + if isArgEmpty(params, "path") { + return nil, ErrNotValid + } + if err := userSession.Backend.Save( + getPath(params, userSession, "path"), + NewReadCloserFromBytes([]byte(GetArgumentsString(params, "content"))), + ); err != nil { + return nil, err + } + return &TextContent{ + Type: "text", + Text: "", + }, nil +} + +func getPath(params map[string]any, userSession *UserSession, name string) string { + path := GetArgumentsString(params, name) + currDir := "" + if path == "" { + currDir = userSession.CurrDir + } else if strings.HasPrefix(path, "~/") { + currDir = "." + strings.TrimPrefix(path, "~") + currDir = JoinPath(userSession.HomeDir, currDir) + } else if strings.HasPrefix(path, "/") { + currDir = path + } else { + currDir = filepath.Join(userSession.CurrDir, ToString(path, "./")) + } + return currDir +} + +func isArgEmpty(params map[string]any, name string) bool { + if arg := GetArgumentsString(params, name); arg == "" { + return true + } + return false +} diff --git a/server/plugin/plg_handler_mcp/index.go b/server/plugin/plg_handler_mcp/index.go new file mode 100644 index 00000000..73075223 --- /dev/null +++ b/server/plugin/plg_handler_mcp/index.go @@ -0,0 +1,42 @@ +package plg_handler_mcp + +import ( + "sync" + + . "github.com/mickael-kerjean/filestash/server/common" + . "github.com/mickael-kerjean/filestash/server/middleware" + . "github.com/mickael-kerjean/filestash/server/plugin/plg_handler_mcp/config" + + "github.com/gorilla/mux" +) + +type Server struct { + sessions sync.Map + expired sync.Map +} + +func init() { + Hooks.Register.Onload(func() { + PluginEnable() + CanEdit() + }) + + Hooks.Register.HttpEndpoint(func(r *mux.Router, app *App) error { + if !PluginEnable() { + return nil + } + srv := Server{} + r.HandleFunc("/sse", srv.sseHandler) + r.HandleFunc("/messages", srv.messageHandler) + r.HandleFunc("/.well-known/oauth-authorization-server", srv.WellKnownInfoHandler) + r.HandleFunc("/mcp/authorize", srv.AuthorizeHandler) + r.HandleFunc("/mcp/token", srv.TokenHandler) + r.HandleFunc("/mcp/register", srv.RegisterHandler) + r.HandleFunc("/api/mcp", NewMiddlewareChain( + srv.CallbackHandler, + []Middleware{SessionStart, LoggedInOnly}, + *app, + )) + return nil + }) +} diff --git a/server/plugin/plg_handler_mcp/types/mcp_completion.go b/server/plugin/plg_handler_mcp/types/mcp_completion.go new file mode 100644 index 00000000..14c39b00 --- /dev/null +++ b/server/plugin/plg_handler_mcp/types/mcp_completion.go @@ -0,0 +1,11 @@ +package types + +type CallCompletionResult struct { + Completion Completion `json:"completion"` +} + +type Completion struct { + Values []string `json:"values"` + Total uint64 `json:"total"` + HasMore bool `json:"hasMore"` +} diff --git a/server/plugin/plg_handler_mcp/types/mcp_init.go b/server/plugin/plg_handler_mcp/types/mcp_init.go new file mode 100644 index 00000000..a5df4187 --- /dev/null +++ b/server/plugin/plg_handler_mcp/types/mcp_init.go @@ -0,0 +1,18 @@ +package types + +type InitializeResult struct { + ProtocolVersion string `json:"protocolVersion"` + ServerInfo ServerInfo `json:"serverInfo"` + Capabilities Capabilities `json:"capabilities"` +} + +type ServerInfo struct { + Name string `json:"name"` + Version string `json:"version"` +} + +type Capabilities struct { + Tools map[string]interface{} `json:"tools",omitempty` + Resources map[string]interface{} `json:"resources",omitempty` + Prompts map[string]interface{} `json:"prompts",omitempty` +} diff --git a/server/plugin/plg_handler_mcp/types/mcp_notification.go b/server/plugin/plg_handler_mcp/types/mcp_notification.go new file mode 100644 index 00000000..23da5127 --- /dev/null +++ b/server/plugin/plg_handler_mcp/types/mcp_notification.go @@ -0,0 +1,18 @@ +package types + +var ( + ErrToolsListChanges = newError("notifications/tools/list_changed") + ErrDisconnect = newError("internal/disconnect") +) + +func newError(s string) error { + return notification{s} +} + +type notification struct { + msg string +} + +func (this notification) Error() string { + return this.msg +} diff --git a/server/plugin/plg_handler_mcp/types/mcp_prompts.go b/server/plugin/plg_handler_mcp/types/mcp_prompts.go new file mode 100644 index 00000000..62d6ef9e --- /dev/null +++ b/server/plugin/plg_handler_mcp/types/mcp_prompts.go @@ -0,0 +1,28 @@ +package types + +type CallPromptsList struct { + Prompts []Prompt `json:"prompts"` + NextCursor string `json:"nextCursor",omitempty` +} + +type Prompt struct { + Name string `json:"name"` + Description string `json:"description"` + Arguments []PromptArgument `json:"arguments"` +} + +type PromptArgument struct { + Name string `json:"name"` + Description string `json:"description"` + Required bool `json:"required"` +} + +type CallPromptGet struct { + Description string `json:"description"` + Messages []PromptMessage `json:"messages"` +} + +type PromptMessage struct { + Role string `json:"role"` + Content TextContent `json:"content"` +} diff --git a/server/plugin/plg_handler_mcp/types/mcp_resources.go b/server/plugin/plg_handler_mcp/types/mcp_resources.go new file mode 100644 index 00000000..91da4e6d --- /dev/null +++ b/server/plugin/plg_handler_mcp/types/mcp_resources.go @@ -0,0 +1,33 @@ +package types + +type CallResourcesList struct { + Resources []Resource `json:"resources"` +} + +type CallResourceTemplatesList struct { + ResourceTemplates []ResourceTemplate `json:"resourceTemplates"` +} + +type CallResourceRead struct { + Contents []ResourceContent `json:"contents"` +} + +type Resource struct { + URI string `json:"uri"` + Name string `json:"name"` + Description string `json:"description"` + MimeType string `json:"mimeType"` +} + +type ResourceTemplate struct { + URITemplate string `json:"uriTemplate"` + Name string `json:"name"` + Description string `json:"description"` + MimeType string `json:"mimeType"` +} + +type ResourceContent struct { + URI string `json:"uri"` + MimeType string `json:"mimeType"` + Text string `json:"text"` +} diff --git a/server/plugin/plg_handler_mcp/types/mcp_tools.go b/server/plugin/plg_handler_mcp/types/mcp_tools.go new file mode 100644 index 00000000..58c9f35c --- /dev/null +++ b/server/plugin/plg_handler_mcp/types/mcp_tools.go @@ -0,0 +1,19 @@ +package types + +import ( + "encoding/json" +) + +type CallListTools struct { + Tools []Tool `json:"tools"` +} + +type Tool struct { + Name string `json:"name"` + Description string `json:"description"` + InputSchema json.RawMessage `json:"inputSchema"` +} + +type CallTool struct { + Content []TextContent `json:"content"` +} diff --git a/server/plugin/plg_handler_mcp/types/resources.go b/server/plugin/plg_handler_mcp/types/resources.go new file mode 100644 index 00000000..3f59d4a2 --- /dev/null +++ b/server/plugin/plg_handler_mcp/types/resources.go @@ -0,0 +1,26 @@ +package types + +import "encoding/json" + +type IResource interface { + Resource() ([]json.RawMessage, error) +} + +type TextContent struct { + Type string `json:"type"` + Text string `json:"text"` +} + +type BinaryContent struct { + URI string `json:uri"` + MimeType string `json:"mimeType"` + Blob []byte `json:"blob"` +} + +func (this TextContent) Resource() ([]byte, error) { + return json.Marshal(this) +} + +func (this BinaryContent) Resource() ([]byte, error) { + return json.Marshal(this) +} diff --git a/server/plugin/plg_handler_mcp/types/rpc.go b/server/plugin/plg_handler_mcp/types/rpc.go new file mode 100644 index 00000000..572fac2e --- /dev/null +++ b/server/plugin/plg_handler_mcp/types/rpc.go @@ -0,0 +1,31 @@ +package types + +type JSONRPCRequest struct { + JSONRPC string `json:"jsonrpc"` + ID uint64 `json:"id"` + Method string `json:"method"` + Params map[string]any `json:"params,omitempty"` +} + +type JSONRPCResponse struct { + JSONRPC string `json:"jsonrpc"` + ID uint64 `json:"id"` + Result *any `json:"result,omitempty"` + Error *JSONRPCError `json:"error,omitempty"` +} + +type JSONRPCMethod struct { + JSONRPC string `json:"jsonrpc"` + Method string `json:"method"` + Params interface{} `json:"params",omitempty` +} + +type JSONRPCError struct { + Code int `json:"code"` + Message string `json:"message"` + Data interface{} `json:"data,omitempty"` +} + +func (this JSONRPCError) Error() string { + return this.Message +} diff --git a/server/plugin/plg_handler_mcp/types/session.go b/server/plugin/plg_handler_mcp/types/session.go new file mode 100644 index 00000000..cee45884 --- /dev/null +++ b/server/plugin/plg_handler_mcp/types/session.go @@ -0,0 +1,22 @@ +package types + +import ( + "time" + + . "github.com/mickael-kerjean/filestash/server/common" +) + +type UserSession struct { + Id string + Chan chan JSONRPCRequest + HomeDir string + CurrDir string + Token string + Backend IBackend + Ping Ping +} + +type Ping struct { + ID uint64 + LastResponse time.Time +} diff --git a/server/plugin/plg_handler_mcp/utils/cors.go b/server/plugin/plg_handler_mcp/utils/cors.go new file mode 100644 index 00000000..ee02f213 --- /dev/null +++ b/server/plugin/plg_handler_mcp/utils/cors.go @@ -0,0 +1,10 @@ +package utils + +import ( + "net/http" +) + +func WithCors(w http.ResponseWriter) { + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Headers", "mcp-protocol-version, Content-Type") +} diff --git a/server/plugin/plg_handler_mcp/utils/default.go b/server/plugin/plg_handler_mcp/utils/default.go new file mode 100644 index 00000000..9ab70177 --- /dev/null +++ b/server/plugin/plg_handler_mcp/utils/default.go @@ -0,0 +1,14 @@ +package utils + +import ( + "fmt" +) + +func ToString(val any, def string) string { + if val == nil { + return def + } else if val == "" { + return def + } + return fmt.Sprintf("%v", val) +} diff --git a/server/plugin/plg_handler_mcp/utils/json.go b/server/plugin/plg_handler_mcp/utils/json.go new file mode 100644 index 00000000..dd09fafc --- /dev/null +++ b/server/plugin/plg_handler_mcp/utils/json.go @@ -0,0 +1,15 @@ +package utils + +import ( + "encoding/json" +) + +func JsonSchema(in any) json.RawMessage { + b, _ := json.Marshal(in) + return json.RawMessage(b) +} + +func JsonText(in any) string { + b, _ := json.MarshalIndent(in, "", " ") + return string(b) +} diff --git a/server/plugin/plg_handler_mcp/utils/mcp.go b/server/plugin/plg_handler_mcp/utils/mcp.go new file mode 100644 index 00000000..ce875b1d --- /dev/null +++ b/server/plugin/plg_handler_mcp/utils/mcp.go @@ -0,0 +1,25 @@ +package utils + +func GetArgumentsString(params map[string]any, name string) string { + m, ok := params["arguments"].(map[string]any) + if !ok { + return "" + } + p, ok := m[name].(string) + if !ok { + return "" + } + return p +} + +func GetArgumentString(params map[string]any, name string) string { + m, ok := params["argument"].(map[string]any) + if !ok { + return "" + } + p, ok := m[name].(string) + if !ok { + return "" + } + return p +} diff --git a/server/plugin/plg_handler_mcp/utils/response.go b/server/plugin/plg_handler_mcp/utils/response.go new file mode 100644 index 00000000..ff68a73a --- /dev/null +++ b/server/plugin/plg_handler_mcp/utils/response.go @@ -0,0 +1,93 @@ +package utils + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + + . "github.com/mickael-kerjean/filestash/server/common" + . "github.com/mickael-kerjean/filestash/server/plugin/plg_handler_mcp/types" +) + +func SendMessage(w io.Writer, requestID uint64, response any) { + b, err := json.Marshal(JSONRPCResponse{ + JSONRPC: "2.0", + ID: requestID, + Result: &response, + }) + if err != nil { + SendError(w, requestID, JSONRPCError{ + Code: http.StatusInternalServerError, + Message: err.Error(), + }) + } + fmt.Fprintf(w, "event: message\ndata: %s\n\n", string(b)) + w.(http.Flusher).Flush() +} + +func SendPing(w io.Writer, requestID uint64) { + b, err := json.Marshal(JSONRPCRequest{ + JSONRPC: "2.0", + ID: requestID, + Method: "ping", + }) + if err != nil { + SendError(w, requestID, JSONRPCError{ + Code: http.StatusInternalServerError, + Message: err.Error(), + }) + } + fmt.Fprintf(w, "event: message\ndata: %s\n\n", string(b)) + w.(http.Flusher).Flush() +} + +func SendMethod(w io.Writer, requestID uint64, method string, args ...map[string]any) { + var params map[string]any + if len(args) == 1 { + params = args[0] + } + + b, err := json.Marshal(JSONRPCMethod{ + JSONRPC: "2.0", + Method: method, + Params: params, + }) + if err != nil { + SendError(w, requestID, JSONRPCError{ + Code: http.StatusInternalServerError, + Message: err.Error(), + }) + } + fmt.Fprintf(w, "event: message\ndata: %s\n\n", string(b)) + w.(http.Flusher).Flush() +} + +func SendError(w io.Writer, requestID uint64, d error) { + var rpcErr JSONRPCError + switch v := d.(type) { + case JSONRPCError: + rpcErr = v + case AppError: + rpcErr = JSONRPCError{ + Code: v.Status(), + Message: v.Error(), + } + default: + rpcErr = JSONRPCError{ + Code: http.StatusInternalServerError, + Message: d.Error(), + } + } + b, err := json.Marshal(JSONRPCResponse{ + JSONRPC: "2.0", + ID: requestID, + Error: &rpcErr, + }) + if err != nil { + fmt.Fprintf(w, "event: message\ndata: %s\n\n", string(`nil`)) + } else { + fmt.Fprintf(w, "event: message\ndata: %s\n\n", string(b)) + } + w.(http.Flusher).Flush() +}