feature (mcp): mcp server

This commit is contained in:
MickaelK 2025-04-01 10:36:28 +11:00
parent f11d27382f
commit 7821bc8681
26 changed files with 1410 additions and 0 deletions

View file

@ -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"

View 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()
}

View 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)
}

View 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)
}

View 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)
}

View 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,
}
}

View 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
}

View 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"
},
})
}

View 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{}
}

View 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)
}

View 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
}

View 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
})
}

View 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"`
}

View 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`
}

View 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
}

View 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"`
}

View 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"`
}

View 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"`
}

View 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)
}

View 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
}

View 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
}

View 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")
}

View 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)
}

View 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)
}

View 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
}

View 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()
}