From 5a566a22a2241f6e75a8cdf6781ad94994ea2e4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mirko=20M=C3=A4licke?= Date: Fri, 20 Feb 2026 09:03:52 +0100 Subject: [PATCH] Add MCP MVP server with stdio/http transports --- README.md | 39 ++++ api/results.go | 39 +++- api/run_specs.go | 81 ++++----- api/runs_state.go | 51 ++---- api/service_helpers.go | 32 ++++ cli/cli.go | 5 + cli/mcp.go | 94 ++++++++++ go.mod | 9 +- go.sum | 15 ++ internal/mcp/server.go | 353 ++++++++++++++++++++++++++++++++++++ internal/mcp/server_test.go | 53 ++++++ internal/service/service.go | 201 ++++++++++++++++++++ 12 files changed, 875 insertions(+), 97 deletions(-) create mode 100644 api/service_helpers.go create mode 100644 cli/mcp.go create mode 100644 internal/mcp/server.go create mode 100644 internal/mcp/server_test.go create mode 100644 internal/service/service.go diff --git a/README.md b/README.md index 3d04215..489f3a8 100644 --- a/README.md +++ b/README.md @@ -115,4 +115,43 @@ curl http://localhost:8080/api/jobs/{job_id} \ - Regularly backup your data directory - Use HTTPS in production +## MCP Server (MVP) + +GoRun includes an MCP server with `stdio` and HTTP transports. + +### Start MCP server + +```bash +# default transport: stdio +gorun mcp serve + +# explicit transport +gorun mcp serve --transport stdio +gorun mcp serve --transport http --mcp-http-addr 127.0.0.1:8091 +gorun mcp serve --transport both --mcp-http-addr 127.0.0.1:8091 +``` + +### MCP HTTP auth + +- Auth is required by default (`Authorization: Bearer `) +- To disable auth for local development only: + +```bash +gorun mcp serve --transport http --mcp-http-no-auth +``` + +### Supported MCP tools + +- `run_tool`: validate + create + start in one call +- `get_run` +- `list_run_results` +- `get_run_result_file` +- `list_specs` +- `get_spec` + +### Supported MCP resources + +- `spec://{toolSlug}` +- `run://{id}/status` +- `run://{id}/results-index` diff --git a/api/results.go b/api/results.go index b8b6c23..7162e15 100644 --- a/api/results.go +++ b/api/results.go @@ -1,10 +1,12 @@ package api import ( - "fmt" + "bytes" + "encoding/base64" "net/http" "github.com/hydrocode-de/gorun/internal/files" + "github.com/hydrocode-de/gorun/internal/service" "github.com/hydrocode-de/gorun/internal/tool" ) @@ -14,9 +16,16 @@ type ListRunResultsResponse struct { } func ListRunResults(w http.ResponseWriter, r *http.Request, tool tool.Tool) { - results, err := tool.ListResults() + userID := r.Header.Get("X-User-ID") + svc := getService() + results, err := svc.ListRunResults(r.Context(), userID, tool.ID) if err != nil { + if service.IsUnauthorized(err) { + RespondWithError(w, http.StatusUnauthorized, err.Error()) + return + } RespondWithError(w, http.StatusInternalServerError, err.Error()) + return } RespondWithJSON(w, http.StatusOK, ListRunResultsResponse{ @@ -26,14 +35,30 @@ func ListRunResults(w http.ResponseWriter, r *http.Request, tool tool.Tool) { } func GetResultFile(w http.ResponseWriter, r *http.Request, tool tool.Tool) { - //filename := r.URL.Query().Get("filename") filename := r.PathValue("filename") - - info, err := tool.WriteResultFile(filename, w) + userID := r.Header.Get("X-User-ID") + svc := getService() + result, err := svc.GetResultFile(r.Context(), userID, tool.ID, filename) if err != nil { + if service.IsUnauthorized(err) { + RespondWithError(w, http.StatusUnauthorized, err.Error()) + return + } RespondWithError(w, http.StatusInternalServerError, err.Error()) + return } - w.Header().Set("Content-Type", info.MimeType) - w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s", info.Filename)) + w.Header().Set("Content-Type", result.Meta.MimeType) + w.Header().Set("Content-Disposition", "attachment; filename="+result.Meta.Filename) + w.Header().Set("X-Result-Path", result.Meta.FullPath) + _, _ = bytes.NewBuffer(result.Content).WriteTo(w) +} + +func encodeResultAsJSONResponse(w http.ResponseWriter, result service.ResultFileContent) { + RespondWithJSON(w, http.StatusOK, map[string]interface{}{ + "filename": result.Meta.Filename, + "mime_type": result.Meta.MimeType, + "path": result.Meta.FullPath, + "content_base64": base64.StdEncoding.EncodeToString(result.Content), + }) } diff --git a/api/run_specs.go b/api/run_specs.go index f3a75db..2ae7c15 100644 --- a/api/run_specs.go +++ b/api/run_specs.go @@ -2,16 +2,12 @@ package api import ( "encoding/json" - "fmt" "net/http" - "strconv" - "github.com/hydrocode-de/gorun/internal/cache" "github.com/hydrocode-de/gorun/internal/db" + "github.com/hydrocode-de/gorun/internal/service" "github.com/hydrocode-de/gorun/internal/tool" toolspec "github.com/hydrocode-de/tool-spec-go" - "github.com/hydrocode-de/tool-spec-go/validate" - "github.com/spf13/viper" ) type ListToolSpecResponse struct { @@ -33,22 +29,9 @@ func RunMiddleware(handler func(http.ResponseWriter, *http.Request, tool.Tool)) RespondWithError(w, http.StatusUnauthorized, "User ID is required") return } - DB := viper.Get("db").(*db.Queries) + DB := getService().DB - idPath := r.PathValue("id") - if idPath == "" { - RespondWithError(w, http.StatusBadRequest, "missing run id") - return - } - id, err := strconv.ParseInt(idPath, 10, 64) - if err != nil { - RespondWithError(w, http.StatusBadRequest, fmt.Sprintf("the passed run id is not a valid integer: %v", err)) - } - - run, err := DB.GetRun(r.Context(), db.GetRunParams{ - ID: id, - UserID: user_id, - }) + run, err := runFromRequest(r.Context(), r, DB, user_id) if err != nil { RespondWithError(w, http.StatusNotFound, err.Error()) return @@ -67,19 +50,21 @@ func GetToolSpec(w http.ResponseWriter, r *http.Request) { toolName := r.PathValue("toolname") if toolName == "" { RespondWithError(w, http.StatusNotFound, "missing tool name") + return } - Cache := viper.Get("cache").(*cache.Cache) - spec, wasFound := Cache.GetToolSpec(toolName) - if !wasFound { + svc := getService() + spec, err := svc.GetToolSpec(toolName) + if err != nil { RespondWithError(w, http.StatusNotFound, "tool not found") + return } RespondWithJSON(w, http.StatusOK, spec) } func ListToolSpecs(w http.ResponseWriter, r *http.Request) { - Cache := viper.Get("cache").(*cache.Cache) - specs := Cache.ListToolSpecs() + svc := getService() + specs := svc.ListToolSpecs(r.URL.Query().Get("filter")) RespondWithJSON(w, http.StatusOK, ListToolSpecResponse{ Count: len(specs), @@ -101,35 +86,33 @@ func CreateRun(w http.ResponseWriter, r *http.Request) { return } - Cache := viper.Get("cache").(*cache.Cache) - toolSlug := fmt.Sprintf("%s::%s", payload.DockerImage, payload.ToolName) - toolSpec, wasFound := Cache.GetToolSpec(toolSlug) - if !wasFound { - RespondWithError(w, http.StatusNotFound, fmt.Sprintf("a tool %s was not found in the cache", toolSlug)) - return - } - hasErrors, errs := validate.ValidateInputs(*toolSpec, toolspec.ToolInput{ - Parameters: payload.Parameters, - Datasets: payload.DataPaths, + svc := getService() + runTool, err := svc.ValidateAndCreateRun(r.Context(), user_id, service.CreateRunInput{ + ToolName: payload.ToolName, + DockerImage: payload.DockerImage, + Parameters: payload.Parameters, + DataPaths: payload.DataPaths, }) - if hasErrors { - RespondWithJSON(w, http.StatusBadRequest, map[string]interface{}{ - "message": fmt.Sprintf("the provided payload is invalid for the tool %s", toolSlug), - "errors": errs, - }) + if err != nil { + if ve, ok := service.IsValidationError(err); ok { + RespondWithJSON(w, http.StatusBadRequest, map[string]interface{}{ + "message": ve.Message, + "errors": ve.Errors, + }) + return + } + if service.IsNotFound(err) { + RespondWithError(w, http.StatusNotFound, err.Error()) + return + } + RespondWithError(w, http.StatusInternalServerError, err.Error()) return } - - // create the mount paths with random strategy - opts := tool.CreateRunOptions{ - Name: payload.ToolName, - Image: payload.DockerImage, - Parameters: payload.Parameters, - Datasets: payload.DataPaths, - } - runData, err := tool.CreateToolRun(r.Context(), "_random", opts, user_id) + DB := getService().DB + runData, err := DB.GetRun(r.Context(), db.GetRunParams{ID: runTool.ID, UserID: user_id}) if err != nil { RespondWithError(w, http.StatusInternalServerError, err.Error()) + return } RespondWithJSON(w, http.StatusCreated, runData) diff --git a/api/runs_state.go b/api/runs_state.go index 395eaec..a23dc6d 100644 --- a/api/runs_state.go +++ b/api/runs_state.go @@ -1,15 +1,13 @@ package api import ( - "context" - "encoding/json" "log" "net/http" "os" "path/filepath" - "time" "github.com/hydrocode-de/gorun/internal/db" + "github.com/hydrocode-de/gorun/internal/service" "github.com/hydrocode-de/gorun/internal/tool" "github.com/spf13/viper" ) @@ -116,30 +114,18 @@ func DeleteRun(w http.ResponseWriter, r *http.Request, tool tool.Tool) { func GetRunStatus(w http.ResponseWriter, r *http.Request, run tool.Tool) { userID := r.Header.Get("X-User-ID") - if userID == "" { - RespondWithError(w, http.StatusUnauthorized, "User ID is required") - return - } - DB := viper.Get("db").(*db.Queries) - - dbRun, err := DB.GetRun(r.Context(), db.GetRunParams{ - ID: run.ID, - UserID: userID, - }) + svc := getService() + detail, err := svc.GetRunDetail(r.Context(), userID, run.ID) if err != nil { + if service.IsUnauthorized(err) { + RespondWithError(w, http.StatusUnauthorized, err.Error()) + return + } RespondWithError(w, http.StatusInternalServerError, err.Error()) return } - resp := RunDetailResponse{Tool: run} - if dbRun.GotapMetadata.Valid { - var metadata interface{} - if err := json.Unmarshal([]byte(dbRun.GotapMetadata.String), &metadata); err != nil { - log.Printf("failed parsing gotap metadata for run %d: %v", run.ID, err) - } else { - resp.GotapMetadata = metadata - } - } + resp := RunDetailResponse{Tool: detail.Tool, GotapMetadata: detail.GotapMetadata} RespondWithJSON(w, http.StatusOK, resp) } @@ -150,26 +136,11 @@ func HandleRunStart(w http.ResponseWriter, r *http.Request, run tool.Tool) { RespondWithError(w, http.StatusUnauthorized, "User ID is required") return } - DB := viper.Get("db").(*db.Queries) - - opt := tool.RunToolOptions{ - DB: DB, - Tool: run, - Env: []string{}, - // Cmd: []string{}, - UserId: user_id, - } - - go tool.RunTool(context.Background(), opt) - - // wait a few miliseconds to make sure the container is started - time.Sleep(time.Millisecond * 100) - started, err := DB.GetRun(r.Context(), db.GetRunParams{ - ID: run.ID, - UserID: user_id, - }) + svc := getService() + started, err := svc.StartRun(r.Context(), user_id, run) if err != nil { RespondWithError(w, http.StatusInternalServerError, err.Error()) + return } RespondWithJSON(w, http.StatusProcessing, started) } diff --git a/api/service_helpers.go b/api/service_helpers.go new file mode 100644 index 0000000..0cad786 --- /dev/null +++ b/api/service_helpers.go @@ -0,0 +1,32 @@ +package api + +import ( + "context" + "fmt" + "net/http" + "strconv" + + "github.com/hydrocode-de/gorun/internal/cache" + "github.com/hydrocode-de/gorun/internal/db" + "github.com/hydrocode-de/gorun/internal/service" + "github.com/spf13/viper" +) + +func getService() *service.Service { + return &service.Service{ + DB: viper.Get("db").(*db.Queries), + Cache: viper.Get("cache").(*cache.Cache), + } +} + +func runFromRequest(ctx context.Context, r *http.Request, DB *db.Queries, userID string) (db.Run, error) { + idPath := r.PathValue("id") + if idPath == "" { + return db.Run{}, fmt.Errorf("missing run id") + } + id, err := strconv.ParseInt(idPath, 10, 64) + if err != nil { + return db.Run{}, fmt.Errorf("the passed run id is not a valid integer: %w", err) + } + return DB.GetRun(ctx, db.GetRunParams{ID: id, UserID: userID}) +} diff --git a/cli/cli.go b/cli/cli.go index 5fe0ede..82c77f2 100644 --- a/cli/cli.go +++ b/cli/cli.go @@ -87,6 +87,11 @@ func initApplicationConfig() { viper.SetDefault("max_upload_size", 1024*1024*1024*2) // 2GB viper.SetDefault("max_temp_age", 12*time.Hour) viper.SetDefault("secret", "") + viper.SetDefault("mcp.enabled", false) + viper.SetDefault("mcp.transport", "stdio") + viper.SetDefault("mcp.http.addr", "127.0.0.1:8091") + viper.SetDefault("mcp.http.auth_required", true) + viper.SetDefault("mcp.http.insecure_no_auth", false) c := &cache.Cache{} c.Reset() diff --git a/cli/mcp.go b/cli/mcp.go new file mode 100644 index 0000000..0111116 --- /dev/null +++ b/cli/mcp.go @@ -0,0 +1,94 @@ +package cli + +import ( + "fmt" + "log" + "net/http" + "os" + + "github.com/hydrocode-de/gorun/internal/auth" + "github.com/hydrocode-de/gorun/internal/cache" + "github.com/hydrocode-de/gorun/internal/db" + "github.com/hydrocode-de/gorun/internal/mcp" + "github.com/hydrocode-de/gorun/internal/service" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +var mcpCmd = &cobra.Command{ + Use: "mcp", + Short: "MCP server commands", +} + +var mcpServeCmd = &cobra.Command{ + Use: "serve", + Short: "Start the GoRun MCP server", + Run: func(cmd *cobra.Command, args []string) { + transport := viper.GetString("mcp.transport") + httpAddr := viper.GetString("mcp.http.addr") + authRequired := viper.GetBool("mcp.http.auth_required") + insecureNoAuth := viper.GetBool("mcp.http.insecure_no_auth") + + if insecureNoAuth { + log.Printf("WARNING: MCP HTTP server running with --mcp-http-no-auth") + } + + startBackgroundTasks(cmd.Context()) + + svc := &service.Service{ + DB: viper.Get("db").(*db.Queries), + Cache: viper.Get("cache").(*cache.Cache), + } + server := mcp.NewServer(svc, mcp.Config{AuthRequired: authRequired, InsecureNoAuth: insecureNoAuth}) + + if _, err := ensureAdminUser(cmd); err != nil { + cobra.CheckErr(err) + } + + switch transport { + case "stdio": + log.Printf("GoRun MCP server listening on stdio") + cobra.CheckErr(server.RunStdio(cmd.Context(), os.Stdin, os.Stdout)) + case "http": + log.Printf("GoRun MCP server listening on http://%s/mcp", httpAddr) + cobra.CheckErr(http.ListenAndServe(httpAddr, server.HTTPHandler())) + case "both": + go func() { + log.Printf("GoRun MCP server listening on http://%s/mcp", httpAddr) + if err := http.ListenAndServe(httpAddr, server.HTTPHandler()); err != nil { + log.Printf("MCP HTTP server error: %v", err) + } + }() + log.Printf("GoRun MCP server listening on stdio") + cobra.CheckErr(server.RunStdio(cmd.Context(), os.Stdin, os.Stdout)) + default: + cobra.CheckErr(fmt.Errorf("invalid mcp transport %q (expected stdio|http|both)", transport)) + } + }, +} + +func ensureAdminUser(cmd *cobra.Command) (string, error) { + credentials, err := auth.GetAdminCredentials(cmd.Context()) + if err != nil { + credentials, err = auth.CreateAdminCredentials(cmd.Context()) + if err != nil { + return "", err + } + } + return credentials.UserID, nil +} + +func init() { + mcpServeCmd.Flags().String("transport", "", "MCP transport to use: stdio|http|both") + mcpServeCmd.Flags().String("mcp-http-addr", "", "MCP HTTP bind address") + mcpServeCmd.Flags().Bool("mcp-http-auth-required", true, "Require Bearer auth for MCP HTTP requests") + mcpServeCmd.Flags().Bool("mcp-http-no-auth", false, "Disable auth for MCP HTTP (unsafe, local dev only)") + + _ = viper.BindPFlag("mcp.transport", mcpServeCmd.Flags().Lookup("transport")) + _ = viper.BindPFlag("mcp.http.addr", mcpServeCmd.Flags().Lookup("mcp-http-addr")) + _ = viper.BindPFlag("mcp.http.auth_required", mcpServeCmd.Flags().Lookup("mcp-http-auth-required")) + _ = viper.BindPFlag("mcp.http.insecure_no_auth", mcpServeCmd.Flags().Lookup("mcp-http-no-auth")) + + mcpCmd.AddCommand(mcpServeCmd) + rootCmd.AddCommand(mcpCmd) +} diff --git a/go.mod b/go.mod index b597175..61d9544 100644 --- a/go.mod +++ b/go.mod @@ -12,17 +12,19 @@ require ( github.com/hydrocode-de/tool-spec-go v0.1.0 github.com/jedib0t/go-pretty/v6 v6.6.7 github.com/joho/godotenv v1.5.1 + github.com/mark3labs/mcp-go v0.44.0 github.com/pressly/goose/v3 v3.24.3 github.com/sirupsen/logrus v1.9.3 github.com/spf13/cobra v1.9.1 github.com/spf13/viper v1.20.1 golang.org/x/crypto v0.39.0 - gopkg.in/yaml.v3 v3.0.1 modernc.org/sqlite v1.38.0 ) require ( github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/bahlo/generic-list-go v0.2.0 // indirect + github.com/buger/jsonparser v1.1.1 // indirect github.com/containerd/errdefs v1.0.0 // indirect github.com/containerd/errdefs/pkg v0.3.0 // indirect github.com/containerd/log v0.1.0 // indirect @@ -37,6 +39,8 @@ require ( github.com/go-viper/mapstructure/v2 v2.3.0 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/invopop/jsonschema v0.13.0 // indirect + github.com/mailru/easyjson v0.7.7 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect github.com/mfridman/interpolate v0.0.2 // indirect @@ -58,6 +62,8 @@ require ( github.com/spf13/cast v1.9.2 // indirect github.com/spf13/pflag v1.0.6 // indirect github.com/subosito/gotenv v1.6.0 // indirect + github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 // indirect go.opentelemetry.io/otel v1.37.0 // indirect @@ -72,6 +78,7 @@ require ( golang.org/x/time v0.14.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20250707201910-8d1bb00bc6a7 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20250707201910-8d1bb00bc6a7 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect gotest.tools/v3 v3.5.2 // indirect modernc.org/libc v1.66.2 // indirect modernc.org/mathutil v1.7.1 // indirect diff --git a/go.sum b/go.sum index f4ece75..4e86592 100644 --- a/go.sum +++ b/go.sum @@ -4,6 +4,10 @@ github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERo github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/alexander-lindner/go-cff v0.5.1 h1:w6yYfE+tIOfwzhjMXTbe8NcckoWGm9urU6pJLhtnWUo= github.com/alexander-lindner/go-cff v0.5.1/go.mod h1:wHMDzQZt9Hdmqe4ir0uSnkBNaA7N3DN8/33J261ekcg= +github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= +github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= +github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= +github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= github.com/cenkalti/backoff/v5 v5.0.2 h1:rIfFVxEf1QsI7E1ZHfp/B4DF/6QBAUhmgkxc0H7Zss8= github.com/cenkalti/backoff/v5 v5.0.2/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= @@ -55,16 +59,23 @@ github.com/hydrocode-de/tool-spec-go v0.1.0 h1:GnAlPDylTmz5n8wIZegYJ5cLzh9MqLAr7 github.com/hydrocode-de/tool-spec-go v0.1.0/go.mod h1:jM1nE1DBIPNCenBbNj6OCbEIImMCza/ANsUzec6trk0= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E= +github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0= github.com/jedib0t/go-pretty/v6 v6.6.7 h1:m+LbHpm0aIAPLzLbMfn8dc3Ht8MW7lsSO4MPItz/Uuo= github.com/jedib0t/go-pretty/v6 v6.6.7/go.mod h1:YwC5CE4fJ1HFUDeivSV1r//AmANFHyqczZk+U6BDALU= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/mark3labs/mcp-go v0.44.0 h1:OlYfcVviAnwNN40QZUrrzU0QZjq3En7rCU5X09a/B7I= +github.com/mark3labs/mcp-go v0.44.0/go.mod h1:YnJfOL382MIWDx1kMY+2zsRHU/q78dBg9aFb8W6Thdw= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= @@ -127,6 +138,10 @@ github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOf github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= +github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= diff --git a/internal/mcp/server.go b/internal/mcp/server.go new file mode 100644 index 0000000..9ae7df6 --- /dev/null +++ b/internal/mcp/server.go @@ -0,0 +1,353 @@ +package mcp + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "strconv" + "strings" + "sync/atomic" + + "github.com/hydrocode-de/gorun/internal/auth" + "github.com/hydrocode-de/gorun/internal/service" + mcpgo "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" + "github.com/spf13/viper" +) + +type contextKey string + +const userIDContextKey contextKey = "mcp_user_id" + +type Config struct { + AuthRequired bool + InsecureNoAuth bool +} + +type Server struct { + svc *service.Service + cfg Config + mcpServer *server.MCPServer + stdioServer *server.StdioServer + httpServer *server.StreamableHTTPServer + warnedInsecureHit atomic.Bool +} + +func NewServer(svc *service.Service, cfg Config) *Server { + s := &Server{svc: svc, cfg: cfg} + s.mcpServer = server.NewMCPServer("gorun-mcp", "0.1.0", + server.WithToolCapabilities(true), + server.WithResourceCapabilities(true, false), + ) + s.registerTools() + s.registerResources() + s.stdioServer = server.NewStdioServer(s.mcpServer) + s.stdioServer.SetContextFunc(func(ctx context.Context) context.Context { + credentials, err := auth.GetAdminCredentials(ctx) + if err != nil { + return context.WithValue(ctx, userIDContextKey, "") + } + return context.WithValue(ctx, userIDContextKey, credentials.UserID) + }) + s.httpServer = server.NewStreamableHTTPServer(s.mcpServer, + server.WithHTTPContextFunc(func(ctx context.Context, r *http.Request) context.Context { + userID := "" + if id, ok := s.authenticateHTTP(r); ok { + userID = id + } + return context.WithValue(ctx, userIDContextKey, userID) + }), + ) + return s +} + +func (s *Server) HTTPHandler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if _, ok := s.authenticateHTTP(r); !ok { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + s.httpServer.ServeHTTP(w, r) + }) +} + +func (s *Server) RunStdio(ctx context.Context, stdin io.Reader, stdout io.Writer) error { + return s.stdioServer.Listen(ctx, stdin, stdout) +} + +func (s *Server) authenticateHTTP(r *http.Request) (string, bool) { + if !s.cfg.AuthRequired || s.cfg.InsecureNoAuth { + if s.cfg.InsecureNoAuth && !s.warnedInsecureHit.Swap(true) { + log.Printf("WARNING: MCP HTTP insecure-no-auth mode is enabled") + } + credentials, err := auth.GetAdminCredentials(r.Context()) + if err != nil { + return "", false + } + return credentials.UserID, true + } + authHeader := r.Header.Get("Authorization") + token := strings.TrimPrefix(authHeader, "Bearer ") + if token == "" || token == authHeader { + return "", false + } + userID, err := auth.ValidateJWT(token, viper.GetString("secret")) + if err != nil { + return "", false + } + return userID, true +} + +func (s *Server) registerTools() { + s.mcpServer.AddTool(mcpgo.NewTool("run_tool", + mcpgo.WithDescription("Validate, create, and start a gorun tool in one call"), + mcpgo.WithString("tool_name", mcpgo.Required(), mcpgo.Description("Tool name from tool spec")), + mcpgo.WithString("docker_image", mcpgo.Required(), mcpgo.Description("Docker image containing the tool")), + mcpgo.WithObject("parameters", mcpgo.Description("Tool parameters object")), + mcpgo.WithObject("data", mcpgo.Description("Input dataset name to host path mapping")), + mcpgo.WithString("client_request_id", mcpgo.Description("Optional client request id")), + ), s.handleRunTool) + + s.mcpServer.AddTool(mcpgo.NewTool("get_run", + mcpgo.WithDescription("Get run status and metadata"), + mcpgo.WithNumber("run_id", mcpgo.Required()), + ), s.handleGetRun) + + s.mcpServer.AddTool(mcpgo.NewTool("list_run_results", + mcpgo.WithDescription("List result files for a run"), + mcpgo.WithNumber("run_id", mcpgo.Required()), + ), s.handleListRunResults) + + s.mcpServer.AddTool(mcpgo.NewTool("get_run_result_file", + mcpgo.WithDescription("Read a result file for a run"), + mcpgo.WithNumber("run_id", mcpgo.Required()), + mcpgo.WithString("filename", mcpgo.Required()), + ), s.handleGetRunResultFile) + + s.mcpServer.AddTool(mcpgo.NewTool("list_specs", + mcpgo.WithDescription("List cached tool specs"), + mcpgo.WithString("filter"), + ), s.handleListSpecs) + + s.mcpServer.AddTool(mcpgo.NewTool("get_spec", + mcpgo.WithDescription("Get a tool spec by slug"), + mcpgo.WithString("tool_slug", mcpgo.Required()), + ), s.handleGetSpec) +} + +func (s *Server) registerResources() { + s.mcpServer.AddResource( + mcpgo.NewResource("spec://{toolSlug}", "Tool spec", mcpgo.WithResourceDescription("Resolve a cached tool specification"), mcpgo.WithMIMEType("application/json")), + s.handleResourceRead, + ) + s.mcpServer.AddResource( + mcpgo.NewResource("run://{id}/status", "Run status", mcpgo.WithResourceDescription("Current run status and metadata"), mcpgo.WithMIMEType("application/json")), + s.handleResourceRead, + ) + s.mcpServer.AddResource( + mcpgo.NewResource("run://{id}/results-index", "Run results", mcpgo.WithResourceDescription("Result file listing for a run"), mcpgo.WithMIMEType("application/json")), + s.handleResourceRead, + ) +} + +func (s *Server) handleRunTool(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + userID := userIDFromContext(ctx) + payload := service.CreateRunInput{ + ToolName: req.GetString("tool_name", ""), + DockerImage: req.GetString("docker_image", ""), + Parameters: toInterfaceMap(req.GetArguments()["parameters"]), + DataPaths: toStringMap(req.GetArguments()["data"]), + } + payload.ClientRequestID = req.GetString("client_request_id", "") + + result, err := s.svc.CreateAndStartRun(ctx, userID, payload) + if err != nil { + return s.toolError(err), nil + } + status := result.Run.Status + if result.StartFailed { + status = "error_start_failed" + } + out := map[string]interface{}{ + "run_id": result.Run.ID, + "status": status, + "created_at": result.Run.CreatedAt, + "started_at": result.Run.StartedAt, + "resource_uris": []string{fmt.Sprintf("run://%d/status", result.Run.ID), fmt.Sprintf("run://%d/results-index", result.Run.ID)}, + "next_steps": []string{"call get_run with run_id", "call list_run_results once status is finished"}, + } + if result.StartFailed { + out["start_error"] = result.StartError + } + return toolJSONResult(out) +} + +func (s *Server) handleGetRun(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + runID, err := parseRunIDArg(req.GetArguments()["run_id"]) + if err != nil { + return mcpgo.NewToolResultError(err.Error()), nil + } + detail, err := s.svc.GetRunDetail(ctx, userIDFromContext(ctx), runID) + if err != nil { + return s.toolError(err), nil + } + return toolJSONResult(detail) +} + +func (s *Server) handleListRunResults(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + runID, err := parseRunIDArg(req.GetArguments()["run_id"]) + if err != nil { + return mcpgo.NewToolResultError(err.Error()), nil + } + results, err := s.svc.ListRunResults(ctx, userIDFromContext(ctx), runID) + if err != nil { + return s.toolError(err), nil + } + return toolJSONResult(map[string]interface{}{"count": len(results), "files": results}) +} + +func (s *Server) handleGetRunResultFile(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + runID, err := parseRunIDArg(req.GetArguments()["run_id"]) + if err != nil { + return mcpgo.NewToolResultError(err.Error()), nil + } + result, err := s.svc.GetResultFile(ctx, userIDFromContext(ctx), runID, req.GetString("filename", "")) + if err != nil { + return s.toolError(err), nil + } + return toolJSONResult(map[string]interface{}{ + "filename": result.Meta.Filename, + "mime_type": result.Meta.MimeType, + "path": result.Meta.FullPath, + "content_base64": base64.StdEncoding.EncodeToString(result.Content), + }) +} + +func (s *Server) handleListSpecs(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + specs := s.svc.ListToolSpecs(req.GetString("filter", "")) + return toolJSONResult(map[string]interface{}{"count": len(specs), "tools": specs}) +} + +func (s *Server) handleGetSpec(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + spec, err := s.svc.GetToolSpec(req.GetString("tool_slug", "")) + if err != nil { + return s.toolError(err), nil + } + return toolJSONResult(spec) +} + +func (s *Server) handleResourceRead(ctx context.Context, req mcpgo.ReadResourceRequest) ([]mcpgo.ResourceContents, error) { + uri := req.Params.URI + userID := userIDFromContext(ctx) + var out interface{} + switch { + case strings.HasPrefix(uri, "spec://"): + slug := strings.TrimPrefix(uri, "spec://") + spec, err := s.svc.GetToolSpec(slug) + if err != nil { + return nil, err + } + out = spec + case strings.HasPrefix(uri, "run://") && strings.HasSuffix(uri, "/status"): + runID, err := parseRunIDFromURI(uri, "/status") + if err != nil { + return nil, err + } + detail, err := s.svc.GetRunDetail(ctx, userID, runID) + if err != nil { + return nil, err + } + out = detail + case strings.HasPrefix(uri, "run://") && strings.HasSuffix(uri, "/results-index"): + runID, err := parseRunIDFromURI(uri, "/results-index") + if err != nil { + return nil, err + } + results, err := s.svc.ListRunResults(ctx, userID, runID) + if err != nil { + return nil, err + } + out = map[string]interface{}{"count": len(results), "files": results} + default: + return nil, fmt.Errorf("resource not found: %s", uri) + } + content, _ := json.Marshal(out) + return []mcpgo.ResourceContents{mcpgo.TextResourceContents{URI: uri, MIMEType: "application/json", Text: string(content)}}, nil +} + +func (s *Server) toolError(err error) *mcpgo.CallToolResult { + if err == nil { + return mcpgo.NewToolResultError("unknown error") + } + if ve, ok := service.IsValidationError(err); ok { + payload, _ := json.Marshal(map[string]interface{}{"message": ve.Message, "errors": ve.Errors}) + return mcpgo.NewToolResultError(string(payload)) + } + return mcpgo.NewToolResultError(err.Error()) +} + +func userIDFromContext(ctx context.Context) string { + v := ctx.Value(userIDContextKey) + if s, ok := v.(string); ok { + return s + } + return "" +} + +func parseRunIDFromURI(uri string, suffix string) (int64, error) { + trimmed := strings.TrimSuffix(strings.TrimPrefix(uri, "run://"), suffix) + trimmed = strings.TrimSuffix(trimmed, "/") + return strconv.ParseInt(trimmed, 10, 64) +} + +func parseRunIDArg(v interface{}) (int64, error) { + switch t := v.(type) { + case float64: + return int64(t), nil + case int64: + return t, nil + case int: + return int64(t), nil + case string: + return strconv.ParseInt(t, 10, 64) + default: + return 0, fmt.Errorf("invalid run_id") + } +} + +func toInterfaceMap(v interface{}) map[string]interface{} { + if v == nil { + return map[string]interface{}{} + } + m, ok := v.(map[string]interface{}) + if !ok { + return map[string]interface{}{} + } + return m +} + +func toStringMap(v interface{}) map[string]string { + out := map[string]string{} + m, ok := v.(map[string]interface{}) + if !ok { + return out + } + for k, raw := range m { + if s, ok := raw.(string); ok { + out[k] = s + } + } + return out +} + +func toolJSONResult(data interface{}) (*mcpgo.CallToolResult, error) { + payload, err := json.Marshal(data) + if err != nil { + return nil, err + } + return mcpgo.NewToolResultText(string(payload)), nil +} diff --git a/internal/mcp/server_test.go b/internal/mcp/server_test.go new file mode 100644 index 0000000..55d45f0 --- /dev/null +++ b/internal/mcp/server_test.go @@ -0,0 +1,53 @@ +package mcp + +import ( + "bytes" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/hydrocode-de/gorun/internal/cache" + "github.com/hydrocode-de/gorun/internal/db" + "github.com/hydrocode-de/gorun/internal/service" + "github.com/spf13/viper" +) + +func newTestServer(authRequired bool) *Server { + svc := &service.Service{DB: &db.Queries{}, Cache: &cache.Cache{}} + return NewServer(svc, Config{AuthRequired: authRequired}) +} + +func TestHTTPAuthRequiredRejectsMissingToken(t *testing.T) { + srv := newTestServer(true) + req := httptest.NewRequest(http.MethodPost, "/mcp", bytes.NewBufferString(`{"jsonrpc":"2.0","id":1,"method":"initialize"}`)) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + srv.HTTPHandler().ServeHTTP(rr, req) + if rr.Code != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d", rr.Code) + } +} + +func TestHTTPAuthRequiredAcceptsValidToken(t *testing.T) { + viper.Set("secret", "test-secret") + token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ + "user_id": "user-123", + "exp": time.Now().Add(time.Hour).Unix(), + }) + tokenString, err := token.SignedString([]byte("test-secret")) + if err != nil { + t.Fatalf("failed to sign token: %v", err) + } + + srv := newTestServer(true) + req := httptest.NewRequest(http.MethodPost, "/mcp", bytes.NewBufferString(`{"jsonrpc":"2.0","id":1,"method":"initialize"}`)) + req.Header.Set("Authorization", "Bearer "+tokenString) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + srv.HTTPHandler().ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("expected 200, got %d body=%s", rr.Code, rr.Body.String()) + } +} diff --git a/internal/service/service.go b/internal/service/service.go new file mode 100644 index 0000000..bd4b22a --- /dev/null +++ b/internal/service/service.go @@ -0,0 +1,201 @@ +package service + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "github.com/hydrocode-de/gorun/internal/cache" + "github.com/hydrocode-de/gorun/internal/db" + "github.com/hydrocode-de/gorun/internal/files" + "github.com/hydrocode-de/gorun/internal/tool" + toolspec "github.com/hydrocode-de/tool-spec-go" + "github.com/hydrocode-de/tool-spec-go/validate" +) + +var ( + ErrUnauthorized = errors.New("user id is required") + ErrNotFound = errors.New("not found") +) + +type Service struct { + DB *db.Queries + Cache *cache.Cache +} + +type CreateRunInput struct { + ToolName string `json:"tool_name"` + DockerImage string `json:"docker_image"` + Parameters map[string]interface{} `json:"parameters"` + DataPaths map[string]string `json:"data"` + ClientRequestID string `json:"client_request_id,omitempty"` +} + +type CreateAndStartResult struct { + Run tool.Tool `json:"run"` + StartFailed bool `json:"start_failed"` + StartError string `json:"start_error,omitempty"` +} + +type RunDetail struct { + Tool tool.Tool `json:"tool"` + GotapMetadata interface{} `json:"gotap_metadata,omitempty"` +} + +type ResultFileContent struct { + Meta tool.WriteFileMeta `json:"meta"` + Content []byte `json:"content"` +} + +type ValidationError struct { + Message string `json:"message"` + Errors interface{} `json:"errors"` +} + +func (s *Service) ListToolSpecs(filter string) []toolspec.ToolSpec { + specs := s.Cache.ListToolSpecs() + if filter == "" { + return specs + } + needle := strings.ToLower(filter) + out := make([]toolspec.ToolSpec, 0, len(specs)) + for _, spec := range specs { + if strings.Contains(strings.ToLower(spec.Name), needle) || strings.Contains(strings.ToLower(spec.Title), needle) { + out = append(out, spec) + } + } + return out +} + +func (s *Service) GetToolSpec(toolSlug string) (*toolspec.ToolSpec, error) { + spec, ok := s.Cache.GetToolSpec(toolSlug) + if !ok { + return nil, fmt.Errorf("%w: tool %s", ErrNotFound, toolSlug) + } + return spec, nil +} + +func (s *Service) ValidateAndCreateRun(ctx context.Context, userID string, payload CreateRunInput) (tool.Tool, error) { + if userID == "" { + return tool.Tool{}, ErrUnauthorized + } + toolSlug := fmt.Sprintf("%s::%s", payload.DockerImage, payload.ToolName) + toolSpec, found := s.Cache.GetToolSpec(toolSlug) + if !found { + return tool.Tool{}, fmt.Errorf("%w: tool %s", ErrNotFound, toolSlug) + } + hasErrors, errs := validate.ValidateInputs(*toolSpec, toolspec.ToolInput{Parameters: payload.Parameters, Datasets: payload.DataPaths}) + if hasErrors { + return tool.Tool{}, ValidationError{Message: fmt.Sprintf("the provided payload is invalid for the tool %s", toolSlug), Errors: errs} + } + + runData, err := tool.CreateToolRun(ctx, "_random", tool.CreateRunOptions{ + Name: payload.ToolName, + Image: payload.DockerImage, + Parameters: payload.Parameters, + Datasets: payload.DataPaths, + }, userID) + if err != nil { + return tool.Tool{}, err + } + + runTool, err := tool.FromDBRun(runData) + if err != nil { + return tool.Tool{}, err + } + return runTool, nil +} + +func (s *Service) StartRun(ctx context.Context, userID string, run tool.Tool) (tool.Tool, error) { + if userID == "" { + return tool.Tool{}, ErrUnauthorized + } + opt := tool.RunToolOptions{DB: s.DB, Tool: run, Env: []string{}, UserId: userID} + go tool.RunTool(context.Background(), opt) + time.Sleep(100 * time.Millisecond) + started, err := s.DB.GetRun(ctx, db.GetRunParams{ID: run.ID, UserID: userID}) + if err != nil { + return tool.Tool{}, err + } + return tool.FromDBRun(started) +} + +func (s *Service) CreateAndStartRun(ctx context.Context, userID string, payload CreateRunInput) (CreateAndStartResult, error) { + runTool, err := s.ValidateAndCreateRun(ctx, userID, payload) + if err != nil { + return CreateAndStartResult{}, err + } + started, err := s.StartRun(ctx, userID, runTool) + if err != nil { + return CreateAndStartResult{Run: runTool, StartFailed: true, StartError: err.Error()}, nil + } + return CreateAndStartResult{Run: started}, nil +} + +func (s *Service) GetRunDetail(ctx context.Context, userID string, runID int64) (RunDetail, error) { + if userID == "" { + return RunDetail{}, ErrUnauthorized + } + dbRun, err := s.DB.GetRun(ctx, db.GetRunParams{ID: runID, UserID: userID}) + if err != nil { + return RunDetail{}, err + } + runTool, err := tool.FromDBRun(dbRun) + if err != nil { + return RunDetail{}, err + } + resp := RunDetail{Tool: runTool} + if dbRun.GotapMetadata.Valid { + var metadata interface{} + if err := json.Unmarshal([]byte(dbRun.GotapMetadata.String), &metadata); err == nil { + resp.GotapMetadata = metadata + } + } + return resp, nil +} + +func (s *Service) ListRunResults(ctx context.Context, userID string, runID int64) ([]files.ResultFile, error) { + detail, err := s.GetRunDetail(ctx, userID, runID) + if err != nil { + return nil, err + } + return detail.Tool.ListResults() +} + +func (s *Service) GetResultFile(ctx context.Context, userID string, runID int64, filename string) (ResultFileContent, error) { + detail, err := s.GetRunDetail(ctx, userID, runID) + if err != nil { + return ResultFileContent{}, err + } + buf := bytes.NewBuffer(nil) + meta, err := detail.Tool.WriteResultFile(filename, buf) + if err != nil { + return ResultFileContent{}, err + } + if meta == nil { + return ResultFileContent{}, errors.New("result file metadata missing") + } + return ResultFileContent{Meta: *meta, Content: buf.Bytes()}, nil +} + +func (e ValidationError) Error() string { + return e.Message +} + +func IsValidationError(err error) (ValidationError, bool) { + var ve ValidationError + ok := errors.As(err, &ve) + return ve, ok +} + +func IsNotFound(err error) bool { + return errors.Is(err, ErrNotFound) || strings.Contains(strings.ToLower(err.Error()), "not found") +} + +func IsUnauthorized(err error) bool { + return errors.Is(err, ErrUnauthorized) +}