diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 1aa883d..8587ff4 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -5,19 +5,18 @@ RUN apt-get update && apt-get install -y \ libxkbcommon0 \ ca-certificates \ git \ - golang \ unzip \ libc++1 \ vim \ + curl \ + procps \ && apt-get clean autoclean +RUN curl -OL https://go.dev/dl/go1.24.0.linux-amd64.tar.gz && \ + tar -C /usr/local -xzvf go1.24.0.linux-amd64.tar.gz && \ + rm go1.24.0.linux-amd64.tar.gz +ENV PATH="$PATH:/usr/local/go/bin" + # Ensure UTF-8 encoding ENV LANG=C.UTF-8 ENV LC_ALL=C.UTF-8 - -ENV GOPATH=/go -ENV PATH=$GOPATH/bin:$PATH - -WORKDIR /workspace - -COPY . /workspace diff --git a/examples/mcp-server/.gitignore b/examples/mcp-server/.gitignore new file mode 100644 index 0000000..cb43e93 --- /dev/null +++ b/examples/mcp-server/.gitignore @@ -0,0 +1 @@ +mcp-server diff --git a/examples/mcp-server/README.md b/examples/mcp-server/README.md new file mode 100644 index 0000000..5d1ff9f --- /dev/null +++ b/examples/mcp-server/README.md @@ -0,0 +1,65 @@ +# Gitpod MCP Server + +A Model Control Protocol (MCP) server that provides access to Gitpod resources and operations through Claude. + +## Features + +- List Gitpod projects +- List Gitpod environments +- Create new environments +- Stop environments +- Execute commands in environments + +## Claude Desktop Configuration + +1. Create a file containing your Gitpod personal access token: +```bash +echo "your_api_key_here" > /tmp/gitpod-personal-access-token.txt +``` + +2. Build the server: +```bash +go build -o /tmp/gitpod-mcp +``` + +3. Add the following to your Claude Desktop configuration to enable Gitpod integration: + +```json +{ + "mcpServers": { + "gitpod": { + "command": "/tmp/gitpod-mcp", + "env": { + "GITPOD_API_KEY": "your-key-here", + } + } + } +} +``` + +>**Note:** Don't forget to delete the token file after you're done. + +## Available Resources + +- `gitpod://projects` - List all available Gitpod projects +- `gitpod://environments` - List current Gitpod environments + +## Available Tools + +### create_environment +Creates a new Gitpod environment for a project. +- Parameters: + - `project_id` (string, required): The ID of the project to create the environment in + +### stop_environment +Stops a running Gitpod environment. +- Parameters: + - `environment_id` (string, required): The ID of the environment to stop + +### execute_command +Executes a command in a Gitpod environment. +- Parameters: + - `environment_id` (string, required): The ID of the environment to execute the command in + - `command` (string, required): The command to execute (runs as a bash script in project root) + - `description` (string, required): A short description of the command (max 200 characters) +``` diff --git a/examples/mcp-server/go.mod b/examples/mcp-server/go.mod new file mode 100644 index 0000000..2bf4fa3 --- /dev/null +++ b/examples/mcp-server/go.mod @@ -0,0 +1,16 @@ +module github.com/gitpod-io/gitpod-sdk-go/examples/mcp-server + +go 1.23.0 + +replace github.com/gitpod-io/gitpod-sdk-go => ../.. + +require github.com/mark3labs/mcp-go v0.8.4 + +require ( + github.com/gitpod-io/gitpod-sdk-go v0.0.0-00010101000000-000000000000 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/tidwall/gjson v1.14.4 // indirect + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.1 // indirect + github.com/tidwall/sjson v1.2.5 // indirect +) diff --git a/examples/mcp-server/go.sum b/examples/mcp-server/go.sum new file mode 100644 index 0000000..c25165c --- /dev/null +++ b/examples/mcp-server/go.sum @@ -0,0 +1,22 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/mark3labs/mcp-go v0.8.4 h1:/VxjJ0+4oN2eYLuAgVzixrYNfrmwJnV38EfPIX3VbPE= +github.com/mark3labs/mcp-go v0.8.4/go.mod h1:cjMlBU0cv/cj9kjlgmRhoJ5JREdS7YX83xeIG9Ko/jE= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/gjson v1.14.4 h1:uo0p8EbA09J7RQaflQ1aBRffTR7xedD2bcIVSYxLnkM= +github.com/tidwall/gjson v1.14.4/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/examples/mcp-server/main.go b/examples/mcp-server/main.go new file mode 100644 index 0000000..4b67004 --- /dev/null +++ b/examples/mcp-server/main.go @@ -0,0 +1,298 @@ +package main + +import ( + "context" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "os" + "strings" + + "github.com/gitpod-io/gitpod-sdk-go" + "github.com/gitpod-io/gitpod-sdk-go/option" + "github.com/gitpod-io/gitpod-sdk-go/shared" + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" +) + +func main() { + slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{ + Level: slog.LevelDebug, + }))) + + var token string + if envvar := os.Getenv("GITPOD_API_KEY"); envvar != "" { + token = envvar + } else if len(os.Args) > 1 { + content, err := os.ReadFile(os.Args[1]) + if err != nil { + slog.ErrorContext(context.Background(), "Failed to read token file", "error", err, "path", os.Args[1]) + return + } + slog.InfoContext(context.Background(), "Using token from file", "path", os.Args[1]) + token = strings.TrimSpace(string(content)) + } + if token == "" { + slog.ErrorContext(context.Background(), "No GITPOD_API_KEY environment variable or file found") + return + } + + client := gitpod.NewClient(option.WithBearerToken(token)) + identity, err := client.Identity.GetAuthenticatedIdentity(context.Background(), gitpod.IdentityGetAuthenticatedIdentityParams{}) + if err != nil { + var apierr *gitpod.Error + if errors.As(err, &apierr) && apierr.StatusCode == 401 { + slog.ErrorContext(context.Background(), "Unauthorized. Did you set the GITPOD_API_KEY environment variable?", "error", apierr.Error()) + } else { + slog.ErrorContext(context.Background(), "Failed to get authenticated identity", "error", err) + } + return + } + userID := identity.Subject.ID + + // Create MCP server + s := server.NewMCPServer( + "Gitpod MCP server", + "0.1.0", + server.WithResourceCapabilities(false, false), + ) + + s.AddResource(mcp.NewResource("gitpod://projects", "projects", + mcp.WithResourceDescription("All available Gitpod projects"), + mcp.WithMIMEType("application/json"), + mcp.WithAnnotations([]mcp.Role{mcp.RoleAssistant}, 0.8), + ), func(ctx context.Context, request mcp.ReadResourceRequest) ([]interface{}, error) { + projects := client.Projects.ListAutoPaging(ctx, gitpod.ProjectListParams{}) + + results := []interface{}{} + for projects.Next() { + results = append(results, projects.Current()) + } + if err := projects.Err(); err != nil { + return nil, err + } + + return results, nil + }) + s.AddResource(mcp.NewResource("gitpod://environments", "environments", + mcp.WithResourceDescription("The user's current Gitpod environments"), + mcp.WithMIMEType("application/json"), + mcp.WithAnnotations([]mcp.Role{mcp.RoleAssistant}, 0.8), + ), func(ctx context.Context, request mcp.ReadResourceRequest) ([]interface{}, error) { + environments := client.Environments.ListAutoPaging(ctx, gitpod.EnvironmentListParams{ + Filter: gitpod.F(gitpod.EnvironmentListParamsFilter{ + CreatorIDs: gitpod.F([]string{userID}), + }), + }) + + results := []interface{}{} + for environments.Next() { + results = append(results, environments.Current()) + } + if err := environments.Err(); err != nil { + return nil, err + } + + return results, nil + }) + + s.AddTool(mcp.NewTool("create_environment", + mcp.WithDescription("Create a new Gitpod environment"), + mcp.WithString("project_id", + mcp.Required(), + mcp.Description("The ID of the project to create the environment in"), + ), + ), toolCreateEnvironment(client)) + s.AddTool(mcp.NewTool("stop_environment", + mcp.WithDescription("Stop a Gitpod environment"), + mcp.WithString("environment_id", + mcp.Required(), + mcp.Description("The ID of the environment to stop"), + ), + ), toolStopEnvironment(client)) + // execute_command + s.AddTool(mcp.NewTool("execute_command", + mcp.WithDescription("Execute a command in a Gitpod environment"), + mcp.WithString("environment_id", + mcp.Required(), + mcp.Description("The ID of the environment to execute the command in"), + ), + mcp.WithString("command", + mcp.Required(), + mcp.Description("The command to execute. Will be executed in the root of the project, as a bash script."), + ), + mcp.WithString("description", + mcp.Required(), + mcp.Description("A short description of the command to execute (no more than 200 characters)"), + ), + ), toolExecuteCommand(client)) + + // Start the stdio server + if err := server.ServeStdio(s); err != nil { + fmt.Printf("Server error: %v\n", err) + } +} + +func toolCreateEnvironment(client *gitpod.Client) server.ToolHandlerFunc { + return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + projectID, ok := request.Params.Arguments["project_id"].(string) + if !ok { + return mcp.NewToolResultError("project_id must be a string"), nil + } + + environment, err := client.Environments.NewFromProject(ctx, gitpod.EnvironmentNewFromProjectParams{ + ProjectID: gitpod.F(projectID), + }) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Failed to create environment: %v", err)), nil + } + return watchEnvironment(client, environment.Environment.ID, func(event gitpod.EventWatchResponse) (*mcp.CallToolResult, error) { + environment, err := client.Environments.Get(ctx, gitpod.EnvironmentGetParams{ + EnvironmentID: gitpod.F(environment.Environment.ID), + }) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Failed to get environment: %v", err)), nil + } + if environment.Environment.Status.Phase == gitpod.EnvironmentPhaseStopping || environment.Environment.Status.Phase == gitpod.EnvironmentPhaseStopped { + return mcp.NewToolResultError(fmt.Sprintf("environment failed to start: %v", environment.Environment.Status.Phase)), nil + } + if environment.Environment.Status.Phase == gitpod.EnvironmentPhaseRunning { + return mcp.NewToolResultText(fmt.Sprintf("Environment created for project %s", projectID)), nil + } + return nil, nil + }) + } +} + +func toolStopEnvironment(client *gitpod.Client) server.ToolHandlerFunc { + return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + environmentID, ok := request.Params.Arguments["environment_id"].(string) + if !ok { + return mcp.NewToolResultError("environment_id must be a string"), nil + } + + _, err := client.Environments.Stop(ctx, gitpod.EnvironmentStopParams{ + EnvironmentID: gitpod.F(environmentID), + }) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Failed to stop environment: %v", err)), nil + } + + return watchEnvironment(client, environmentID, func(event gitpod.EventWatchResponse) (*mcp.CallToolResult, error) { + environment, err := client.Environments.Get(ctx, gitpod.EnvironmentGetParams{ + EnvironmentID: gitpod.F(environmentID), + }) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Failed to get environment: %v", err)), nil + } + if environment.Environment.Status.Phase == gitpod.EnvironmentPhaseStopped { + return mcp.NewToolResultText("Environment stopped"), nil + } + return nil, nil + }) + } +} + +func watchEnvironment[T any](client *gitpod.Client, environmentID string, onEvent func(event gitpod.EventWatchResponse) (*T, error)) (*T, error) { + events := client.Events.WatchStreaming(context.Background(), gitpod.EventWatchParams{ + EnvironmentID: gitpod.F(environmentID), + }) + for events.Next() { + event := events.Current() + res, err := onEvent(event) + if err != nil { + return nil, err + } + if res != nil { + return res, nil + } + } + if err := events.Err(); err != nil { + return nil, err + } + return nil, nil +} + +func toolExecuteCommand(client *gitpod.Client) server.ToolHandlerFunc { + return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + environmentID, ok := request.Params.Arguments["environment_id"].(string) + if !ok { + return mcp.NewToolResultError("environment_id must be a string"), nil + } + command, ok := request.Params.Arguments["command"].(string) + if !ok { + return mcp.NewToolResultError("command must be a string"), nil + } + description, ok := request.Params.Arguments["description"].(string) + if !ok { + return mcp.NewToolResultError("description must be a string"), nil + } + + task, err := client.Environments.Automations.Tasks.New(ctx, gitpod.EnvironmentAutomationTaskNewParams{ + EnvironmentID: gitpod.F(environmentID), + Metadata: gitpod.F(shared.TaskMetadataParam{ + Name: gitpod.F(command), + Description: gitpod.F(description), + }), + Spec: gitpod.F(shared.TaskSpecParam{ + Command: gitpod.F(command), + }), + }) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Failed to create task: %v", err)), nil + } + defer client.Environments.Automations.Tasks.Delete(ctx, gitpod.EnvironmentAutomationTaskDeleteParams{ + ID: gitpod.F(task.Task.ID), + }) + + run, err := client.Environments.Automations.Tasks.Start(ctx, gitpod.EnvironmentAutomationTaskStartParams{ + ID: gitpod.F(task.Task.ID), + }) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Failed to start task: %v", err)), nil + } + logURL, err := watchEnvironment(client, environmentID, func(event gitpod.EventWatchResponse) (*string, error) { + resp, err := client.Environments.Automations.Tasks.Executions.Get(ctx, gitpod.EnvironmentAutomationTaskExecutionGetParams{ + ID: gitpod.F(run.TaskExecution.ID), + }) + if err != nil { + return nil, fmt.Errorf("Failed to get task: %v", err) + } + if resp.TaskExecution.Status.LogURL != "" { + return &resp.TaskExecution.Status.LogURL, nil + } + return nil, nil + }) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Failed to get task: %v", err)), nil + } + + logToken, err := client.Environments.NewLogsToken(ctx, gitpod.EnvironmentNewLogsTokenParams{ + EnvironmentID: gitpod.F(environmentID), + }) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Failed to get log token: %v", err)), nil + } + + req, err := http.NewRequestWithContext(ctx, "GET", *logURL, nil) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Failed to create request: %v", err)), nil + } + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", logToken.AccessToken)) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Failed to get log: %v", err)), nil + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Failed to read log: %v", err)), nil + } + + return mcp.NewToolResultText(fmt.Sprintf("%s", string(body))), nil + } +}