Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <jwt>`)
- 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`

39 changes: 32 additions & 7 deletions api/results.go
Original file line number Diff line number Diff line change
@@ -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"
)

Expand All @@ -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{
Expand All @@ -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),
})
}
81 changes: 32 additions & 49 deletions api/run_specs.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Expand All @@ -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),
Expand All @@ -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)
Expand Down
51 changes: 11 additions & 40 deletions api/runs_state.go
Original file line number Diff line number Diff line change
@@ -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"
)
Expand Down Expand Up @@ -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)
}
Expand All @@ -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)
}
32 changes: 32 additions & 0 deletions api/service_helpers.go
Original file line number Diff line number Diff line change
@@ -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})
}
5 changes: 5 additions & 0 deletions cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading