mirror of
https://github.com/mickael-kerjean/filestash
synced 2025-12-06 08:22:24 +01:00
feature (mcp): mcp server
This commit is contained in:
parent
f11d27382f
commit
7821bc8681
26 changed files with 1410 additions and 0 deletions
|
|
@ -29,6 +29,7 @@ import (
|
||||||
_ "github.com/mickael-kerjean/filestash/server/plugin/plg_backend_webdav"
|
_ "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_editor_wopi"
|
||||||
_ "github.com/mickael-kerjean/filestash/server/plugin/plg_handler_console"
|
_ "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_ascii"
|
||||||
_ "github.com/mickael-kerjean/filestash/server/plugin/plg_image_c"
|
_ "github.com/mickael-kerjean/filestash/server/plugin/plg_image_c"
|
||||||
_ "github.com/mickael-kerjean/filestash/server/plugin/plg_image_transcode"
|
_ "github.com/mickael-kerjean/filestash/server/plugin/plg_image_transcode"
|
||||||
|
|
|
||||||
33
server/plugin/plg_handler_mcp/config/config.go
Normal file
33
server/plugin/plg_handler_mcp/config/config.go
Normal file
|
|
@ -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()
|
||||||
|
}
|
||||||
213
server/plugin/plg_handler_mcp/handler.go
Normal file
213
server/plugin/plg_handler_mcp/handler.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
116
server/plugin/plg_handler_mcp/handler_auth.go
Normal file
116
server/plugin/plg_handler_mcp/handler_auth.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
39
server/plugin/plg_handler_mcp/handler_state.go
Normal file
39
server/plugin/plg_handler_mcp/handler_state.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
50
server/plugin/plg_handler_mcp/impl/completion.go
Normal file
50
server/plugin/plg_handler_mcp/impl/completion.go
Normal file
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
46
server/plugin/plg_handler_mcp/impl/prompts.go
Normal file
46
server/plugin/plg_handler_mcp/impl/prompts.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
113
server/plugin/plg_handler_mcp/impl/prompts_fs.go
Normal file
113
server/plugin/plg_handler_mcp/impl/prompts_fs.go
Normal file
|
|
@ -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"
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
17
server/plugin/plg_handler_mcp/impl/resources.go
Normal file
17
server/plugin/plg_handler_mcp/impl/resources.go
Normal file
|
|
@ -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{}
|
||||||
|
}
|
||||||
37
server/plugin/plg_handler_mcp/impl/tools.go
Normal file
37
server/plugin/plg_handler_mcp/impl/tools.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
340
server/plugin/plg_handler_mcp/impl/tools_fs.go
Normal file
340
server/plugin/plg_handler_mcp/impl/tools_fs.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
42
server/plugin/plg_handler_mcp/index.go
Normal file
42
server/plugin/plg_handler_mcp/index.go
Normal file
|
|
@ -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
|
||||||
|
})
|
||||||
|
}
|
||||||
11
server/plugin/plg_handler_mcp/types/mcp_completion.go
Normal file
11
server/plugin/plg_handler_mcp/types/mcp_completion.go
Normal file
|
|
@ -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"`
|
||||||
|
}
|
||||||
18
server/plugin/plg_handler_mcp/types/mcp_init.go
Normal file
18
server/plugin/plg_handler_mcp/types/mcp_init.go
Normal file
|
|
@ -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`
|
||||||
|
}
|
||||||
18
server/plugin/plg_handler_mcp/types/mcp_notification.go
Normal file
18
server/plugin/plg_handler_mcp/types/mcp_notification.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
28
server/plugin/plg_handler_mcp/types/mcp_prompts.go
Normal file
28
server/plugin/plg_handler_mcp/types/mcp_prompts.go
Normal file
|
|
@ -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"`
|
||||||
|
}
|
||||||
33
server/plugin/plg_handler_mcp/types/mcp_resources.go
Normal file
33
server/plugin/plg_handler_mcp/types/mcp_resources.go
Normal file
|
|
@ -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"`
|
||||||
|
}
|
||||||
19
server/plugin/plg_handler_mcp/types/mcp_tools.go
Normal file
19
server/plugin/plg_handler_mcp/types/mcp_tools.go
Normal file
|
|
@ -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"`
|
||||||
|
}
|
||||||
26
server/plugin/plg_handler_mcp/types/resources.go
Normal file
26
server/plugin/plg_handler_mcp/types/resources.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
31
server/plugin/plg_handler_mcp/types/rpc.go
Normal file
31
server/plugin/plg_handler_mcp/types/rpc.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
22
server/plugin/plg_handler_mcp/types/session.go
Normal file
22
server/plugin/plg_handler_mcp/types/session.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
10
server/plugin/plg_handler_mcp/utils/cors.go
Normal file
10
server/plugin/plg_handler_mcp/utils/cors.go
Normal file
|
|
@ -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")
|
||||||
|
}
|
||||||
14
server/plugin/plg_handler_mcp/utils/default.go
Normal file
14
server/plugin/plg_handler_mcp/utils/default.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
15
server/plugin/plg_handler_mcp/utils/json.go
Normal file
15
server/plugin/plg_handler_mcp/utils/json.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
25
server/plugin/plg_handler_mcp/utils/mcp.go
Normal file
25
server/plugin/plg_handler_mcp/utils/mcp.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
93
server/plugin/plg_handler_mcp/utils/response.go
Normal file
93
server/plugin/plg_handler_mcp/utils/response.go
Normal file
|
|
@ -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()
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue