diff --git a/bin/harness-mcp-server b/bin/harness-mcp-server new file mode 100755 index 00000000..2f65f36c Binary files /dev/null and b/bin/harness-mcp-server differ diff --git a/client/branch.go b/client/branch.go new file mode 100644 index 00000000..36218bcb --- /dev/null +++ b/client/branch.go @@ -0,0 +1,9 @@ +package client + +// This file previously contained the BranchService implementation for branch operations +// (Create, CommitFile, CommitMultipleFiles). +// These operations have been removed as requested. + +// BranchService struct is kept as an empty placeholder to avoid breaking existing code +type BranchService struct { +} diff --git a/client/branch_operations.go b/client/branch_operations.go new file mode 100644 index 00000000..095b42cc --- /dev/null +++ b/client/branch_operations.go @@ -0,0 +1,9 @@ +package client + +// This file previously contained the BranchOperationsService implementation for branch operations +// (CreateBranch, CommitFile, CommitMultipleFiles). +// These operations have been removed as requested. + +// BranchOperationsService struct is kept as an empty placeholder to avoid breaking existing code +type BranchOperationsService struct { +} diff --git a/client/custom_branch_ops.go b/client/custom_branch_ops.go new file mode 100644 index 00000000..56a7a0ab --- /dev/null +++ b/client/custom_branch_ops.go @@ -0,0 +1,9 @@ +package client + +// This file previously contained the CustomBranchOpsService implementation for branch operations +// (CreateBranch, CommitFile, CommitMultipleFiles). +// These operations have been removed as requested. + +// CustomBranchOpsService struct is kept as an empty placeholder to avoid breaking existing code +type CustomBranchOpsService struct { +} diff --git a/client/dto/branch.go b/client/dto/branch.go new file mode 100644 index 00000000..8dba8dd1 --- /dev/null +++ b/client/dto/branch.go @@ -0,0 +1,8 @@ +package dto + +// This file previously contained DTOs for branch operations: +// - CreateBranchRequest and CreateBranchResponse +// - CommitFileRequest and CommitFileResponse +// - CommitMultipleFilesRequest and CommitFileOp +// +// These DTOs have been removed as requested. diff --git a/client/dto/branch_ops.go b/client/dto/branch_ops.go new file mode 100644 index 00000000..041becdb --- /dev/null +++ b/client/dto/branch_ops.go @@ -0,0 +1,8 @@ +package dto + +// This file previously contained DTOs for branch operations: +// - BranchCreateRequest and BranchCreateResponse +// - FileCommitRequest and FileCommitResponse +// - MultiFileCommitRequest and FileOperation +// +// These DTOs have been removed as requested. diff --git a/client/dto/custom_branch_ops.go b/client/dto/custom_branch_ops.go new file mode 100644 index 00000000..c1c1bbeb --- /dev/null +++ b/client/dto/custom_branch_ops.go @@ -0,0 +1,8 @@ +package dto + +// This file previously contained DTOs for custom branch operations: +// - CustomBranchCreateRequest and CustomBranchCreateResponse +// - CustomFileCommitRequest and CustomFileCommitResponse +// - CustomMultiFileCommitRequest and CustomFileOperation +// +// These DTOs have been removed as requested. diff --git a/client/dto/repositories.go b/client/dto/repositories.go index 03d7a703..dad4b2d9 100644 --- a/client/dto/repositories.go +++ b/client/dto/repositories.go @@ -38,3 +38,52 @@ type RepositoryOptions struct { Page int `json:"page,omitempty"` Limit int `json:"limit,omitempty"` } + +// FileContentRequest represents a request to get file content from a commit +type FileContentRequest struct { + Path string `json:"path"` + GitRef string `json:"git_ref"` + OrgID string `json:"org_id,omitempty"` + ProjectID string `json:"project_id,omitempty"` + RoutingID string `json:"routing_id,omitempty"` +} + +// FileContent represents the content of a file at a specific commit +type FileContent struct { + Type string `json:"type"` + Sha string `json:"sha"` + Name string `json:"name"` + Path string `json:"path"` + LatestCommit *Commit `json:"latest_commit,omitempty"` + Content *EncodedContent `json:"content,omitempty"` +} + +// EncodedContent represents encoded content of a file +type EncodedContent struct { + Encoding string `json:"encoding"` + Data string `json:"data"` + Size int `json:"size"` + DataSize int `json:"data_size"` +} + +// Commit represents a git commit +type Commit struct { + Sha string `json:"sha"` + ParentShas []string `json:"parent_shas,omitempty"` + Title string `json:"title"` + Message string `json:"message"` + Author *Signature `json:"author,omitempty"` + Committer *Signature `json:"committer,omitempty"` +} + +// Signature represents author or committer information +type Signature struct { + Identity *Identity `json:"identity,omitempty"` + When string `json:"when,omitempty"` +} + +// Identity represents a user identity +type Identity struct { + Name string `json:"name,omitempty"` + Email string `json:"email,omitempty"` +} diff --git a/client/repositories.go b/client/repositories.go index b0e63c85..c8e9ca4a 100644 --- a/client/repositories.go +++ b/client/repositories.go @@ -3,6 +3,8 @@ package client import ( "context" "fmt" + "net/url" + "strings" "github.com/harness/harness-mcp/client/dto" ) @@ -11,6 +13,9 @@ const ( repositoryBasePath = "code/api/v1/repos" repositoryGetPath = repositoryBasePath + "/%s" repositoryListPath = repositoryBasePath + // Path for getting file content from a commit + // Format: /code/api/v1/repos/{account}/{org}/{project}/{repo}/+/content/{file_path} + fileContentPath = repositoryBasePath + "/%s/%s/%s/%s/+/content/%s" ) type RepositoryService struct { @@ -80,3 +85,73 @@ func (r *RepositoryService) List(ctx context.Context, scope dto.Scope, opts *dto return repos, nil } + +// GetFileContent retrieves file content from a specific commit or branch +func (r *RepositoryService) GetFileContent(ctx context.Context, scope dto.Scope, repoIdentifier string, req *dto.FileContentRequest) (*dto.FileContent, error) { + // Extract account, org, project from scope or use defaults + account := scope.AccountID + if account == "" { + account = repoIdentifier // Use repo identifier as account if not specified + } + + org := scope.OrgID + if org == "" { + org = "default" // Use default org if not specified + } + + project := scope.ProjectID + if project == "" && req.ProjectID != "" { + project = req.ProjectID + } + + // Construct the path with all components + path := fmt.Sprintf(fileContentPath, account, org, project, repoIdentifier, url.PathEscape(req.Path)) + + // Set up query parameters + params := make(map[string]string) + + // Add routing ID if provided + if req.RoutingID != "" { + params["routingId"] = req.RoutingID + } else if account != "" { + // Use account as routing ID if not explicitly provided + params["routingId"] = account + } + + // Add git_ref (commit ID, branch name, or tag) parameter + if req.GitRef != "" { + // Handle different types of git references + if strings.HasPrefix(req.GitRef, "refs/") { + // Already a fully qualified reference + params["git_ref"] = req.GitRef + } else if len(req.GitRef) == 40 || len(req.GitRef) >= 7 && len(req.GitRef) < 40 && isHexString(req.GitRef) { + // Looks like a commit SHA (full 40 char or abbreviated) + params["git_ref"] = req.GitRef + } else { + // Assume it's a branch name or tag, use refs/heads/ prefix + params["git_ref"] = "refs/heads/" + req.GitRef + } + } + + // Add include_commit parameter to get commit information + params["include_commit"] = "true" + + // Make the API request + fileContent := new(dto.FileContent) + err := r.Client.Get(ctx, path, params, nil, fileContent) + if err != nil { + return nil, fmt.Errorf("failed to get file content: %w", err) + } + + return fileContent, nil +} + +// isHexString checks if a string contains only hexadecimal characters +func isHexString(s string) bool { + for _, r := range s { + if !((r >= '0' && r <= '9') || (r >= 'a' && r <= 'f') || (r >= 'A' && r <= 'F')) { + return false + } + } + return true +} diff --git a/pkg/harness/branch.go b/pkg/harness/branch.go new file mode 100644 index 00000000..d631bd93 --- /dev/null +++ b/pkg/harness/branch.go @@ -0,0 +1,5 @@ +package harness + +// This file previously contained the branch operation tools (CreateBranchTool, CommitFileTool, and CommitMultipleFilesTool) +// These tools have been removed as requested. + diff --git a/pkg/harness/branch_operations.go b/pkg/harness/branch_operations.go new file mode 100644 index 00000000..b697e143 --- /dev/null +++ b/pkg/harness/branch_operations.go @@ -0,0 +1,5 @@ +package harness + +// This file previously contained the branch operation tools (CreateBranchOperationTool, CommitFileOperationTool, and CommitMultipleFilesOperationTool) +// These tools have been removed as requested. + diff --git a/pkg/harness/commit_changes.go b/pkg/harness/commit_changes.go new file mode 100644 index 00000000..986cb9bb --- /dev/null +++ b/pkg/harness/commit_changes.go @@ -0,0 +1,187 @@ +package harness + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + + "github.com/harness/harness-mcp/cmd/harness-mcp-server/config" + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" +) + +// FileChange represents a change to a file in a commit +type FileChange struct { + Action string `json:"action"` + Path string `json:"path"` + Payload string `json:"payload,omitempty"` + Sha string `json:"sha,omitempty"` +} + +// CommitRequest represents the request body for committing changes +type CommitRequest struct { + Actions []FileChange `json:"actions"` + Branch string `json:"branch"` + NewBranch string `json:"new_branch"` + Title string `json:"title"` + Message string `json:"message"` + BypassRules bool `json:"bypass_rules"` +} + +// CommitChangesTool creates a tool for committing changes to files in a repository +func CommitChangesTool(config *config.Config) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("commit_changes", + mcp.WithDescription("Commit changes to files in a Harness repository."), + mcp.WithString("repo_identifier", + mcp.Required(), + mcp.Description("The identifier of the repository"), + ), + mcp.WithString("branch", + mcp.Required(), + mcp.Description("The branch to commit changes to"), + ), + mcp.WithString("new_branch", + mcp.Description("Optional new branch to create for this commit. Leave empty to commit to existing branch."), + ), + mcp.WithString("title", + mcp.Required(), + mcp.Description("The title of the commit"), + ), + mcp.WithString("message", + mcp.Description("Optional detailed commit message"), + ), + mcp.WithArray("file_changes", + mcp.Required(), + mcp.Description("Array of file changes to commit"), + ), + mcp.WithBoolean("bypass_rules", + mcp.Description("Whether to bypass branch protection rules"), + ), + WithScope(config, false), + ), + func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + repoIdentifier, err := requiredParam[string](request, "repo_identifier") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + branch, err := requiredParam[string](request, "branch") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + title, err := requiredParam[string](request, "title") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + newBranch, _ := OptionalParam[string](request, "new_branch") + message, _ := OptionalParam[string](request, "message") + bypassRules, _ := OptionalParam[bool](request, "bypass_rules") + + // Extract file changes + fileChangesRaw, err := requiredParam[interface{}](request, "file_changes") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + fileChangesArray, ok := fileChangesRaw.([]interface{}) + if !ok { + return mcp.NewToolResultError("file_changes must be an array"), nil + } + + if len(fileChangesArray) == 0 { + return mcp.NewToolResultError("file_changes cannot be empty"), nil + } + + // Convert file changes to the expected format + fileChanges := make([]FileChange, 0, len(fileChangesArray)) + for _, changeRaw := range fileChangesArray { + changeMap, ok := changeRaw.(map[string]interface{}) + if !ok { + return mcp.NewToolResultError("each file change must be an object"), nil + } + + action, ok := changeMap["action"].(string) + if !ok || action == "" { + return mcp.NewToolResultError("action is required for each file change"), nil + } + + path, ok := changeMap["path"].(string) + if !ok || path == "" { + return mcp.NewToolResultError("path is required for each file change"), nil + } + + payload, _ := changeMap["payload"].(string) + sha, _ := changeMap["sha"].(string) + + fileChanges = append(fileChanges, FileChange{ + Action: action, + Path: path, + Payload: payload, + Sha: sha, + }) + } + + scope, err := fetchScope(config, request, false) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + // Create request body + commitRequest := CommitRequest{ + Actions: fileChanges, + Branch: branch, + NewBranch: newBranch, + Title: title, + Message: message, + BypassRules: bypassRules, + } + + // Build URL + url := fmt.Sprintf("%sgateway/code/api/v1/repos/%s/%s/%s/%s/+/commits?routingId=%s", + config.BaseURL, config.AccountID, scope.OrgID, scope.ProjectID, repoIdentifier, config.AccountID) + + // Make HTTP request + client := &http.Client{} + bodyBytes, err := json.Marshal(commitRequest) + if err != nil { + return nil, fmt.Errorf("failed to marshal request body: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, strings.NewReader(string(bodyBytes))) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + // Add auth header from config + if config.APIKey != "" { + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", config.APIKey)) + } + + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to execute request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { + return nil, fmt.Errorf("failed to commit changes: received status code %d", resp.StatusCode) + } + + var responseData map[string]interface{} + if err := json.NewDecoder(resp.Body).Decode(&responseData); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + r, err := json.Marshal(responseData) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } +} diff --git a/pkg/harness/commit_diff.go b/pkg/harness/commit_diff.go new file mode 100644 index 00000000..0324f8ac --- /dev/null +++ b/pkg/harness/commit_diff.go @@ -0,0 +1,118 @@ +package harness + +import ( + "context" + "fmt" + "io" + "net/http" + "strings" + + "github.com/harness/harness-mcp/client" + "github.com/harness/harness-mcp/cmd/harness-mcp-server/config" + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" +) + +// GetCommitDiffTool creates a tool for retrieving the diff of a specific commit +func GetCommitDiffTool(appConfig *config.Config, harnessClient *client.Client) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("get_commit_diff", + mcp.WithDescription("Get the diff for a specific commit in a Harness repository."), + mcp.WithString("repo_identifier", + mcp.Required(), + mcp.Description("The identifier of the repository"), + ), + mcp.WithString("commit_id", + mcp.Required(), + mcp.Description("The commit ID (SHA) to retrieve the diff for"), + ), + WithScope(appConfig, true), // Indicates org_id, project_id are parameters, account_id from config + ), + func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + // Extract parameters + repoIdentifier, err := requiredParam[string](request, "repo_identifier") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + commitID, err := requiredParam[string](request, "commit_id") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + orgID, err := OptionalParam[string](request, "org_id") + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Error parsing org_id: %v", err.Error())), nil + } + if orgID == "" { + orgID = appConfig.DefaultOrgID + } + if orgID == "" { + return mcp.NewToolResultError("Organization ID (org_id) is required but not provided and no default is set."), nil + } + + projectID, err := OptionalParam[string](request, "project_id") + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Error parsing project_id: %v", err.Error())), nil + } + if projectID == "" { + projectID = appConfig.DefaultProjectID + } + if projectID == "" { + return mcp.NewToolResultError("Project ID (project_id) is required but not provided and no default is set."), nil + } + + accountID := appConfig.AccountID + if accountID == "" { + return mcp.NewToolResultError("Harness Account ID (HARNESS_ACCOUNT_ID) not configured in the MCP server environment."), nil + } + + // Construct the URL path + // Example: /gateway/code/api/v1/repos/{account_id}/{org_id}/{project_id}/{repo_identifier}/+/commits/{commit_id}/diff + relativePath := fmt.Sprintf("/gateway/code/api/v1/repos/%s/%s/%s/%s/+/commits/%s/diff", + accountID, + orgID, + projectID, + repoIdentifier, + commitID, + ) + + url := strings.TrimSuffix(harnessClient.BaseURL.String(), "/") + relativePath + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + // Add routingId query parameter + q := req.URL.Query() + q.Add("routingId", accountID) + req.URL.RawQuery = q.Encode() + + // Set headers + req.Header.Set("Accept", "text/plain") + + authHeaderKey, authHeaderValue, err := harnessClient.AuthProvider.GetHeader(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get auth header: %w", err) + } + req.Header.Set(authHeaderKey, authHeaderValue) + + resp, err := harnessClient.Do(req) // Use the Do method of client.Client + if err != nil { + return nil, fmt.Errorf("failed to execute request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + bodyBytes, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("API request to %s failed with status %s: %s", url, resp.Status, string(bodyBytes)) + } + + diffBytes, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + + return mcp.NewToolResultText(string(diffBytes)), nil + } +} diff --git a/pkg/harness/create_branch.go b/pkg/harness/create_branch.go new file mode 100644 index 00000000..64aaf3ae --- /dev/null +++ b/pkg/harness/create_branch.go @@ -0,0 +1,110 @@ +package harness + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + + "github.com/harness/harness-mcp/cmd/harness-mcp-server/config" + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" +) + +// CreateBranchTool creates a tool for creating a new branch in a repository +func CreateBranchTool(config *config.Config) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("create_branch", + mcp.WithDescription("Create a new branch in a Harness repository."), + mcp.WithString("repo_identifier", + mcp.Required(), + mcp.Description("The identifier of the repository"), + ), + mcp.WithString("branch_name", + mcp.Required(), + mcp.Description("The name of the new branch to create"), + ), + mcp.WithString("target_branch", + mcp.DefaultString("master"), + mcp.Description("The target branch from which to create the new branch"), + ), + mcp.WithBoolean("bypass_rules", + mcp.Description("Whether to bypass branch protection rules"), + ), + WithScope(config, false), + ), + func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + repoIdentifier, err := requiredParam[string](request, "repo_identifier") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + branchName, err := requiredParam[string](request, "branch_name") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + targetBranch, err := OptionalParam[string](request, "target_branch") + if err != nil || targetBranch == "" { + targetBranch = "master" + } + + bypassRules, _ := OptionalParam[bool](request, "bypass_rules") + + scope, err := fetchScope(config, request, false) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + // Create request body + requestBody := map[string]interface{}{ + "name": branchName, + "target": fmt.Sprintf("refs/heads/%s", targetBranch), + "bypass_rules": bypassRules, + } + + // Build URL + url := fmt.Sprintf("%sgateway/code/api/v1/repos/%s/%s/%s/%s/+/branches?routingId=%s", + config.BaseURL, config.AccountID, scope.OrgID, scope.ProjectID, repoIdentifier, config.AccountID) + + // Make HTTP request + client := &http.Client{} + bodyBytes, err := json.Marshal(requestBody) + if err != nil { + return nil, fmt.Errorf("failed to marshal request body: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, strings.NewReader(string(bodyBytes))) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + // Add auth header from config + if config.APIKey != "" { + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", config.APIKey)) + } + + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to execute request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { + return nil, fmt.Errorf("failed to create branch: received status code %d", resp.StatusCode) + } + + var responseData map[string]interface{} + if err := json.NewDecoder(resp.Body).Decode(&responseData); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + r, err := json.Marshal(responseData) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } +} diff --git a/pkg/harness/create_branch_and_commit.go b/pkg/harness/create_branch_and_commit.go new file mode 100644 index 00000000..a80ebf45 --- /dev/null +++ b/pkg/harness/create_branch_and_commit.go @@ -0,0 +1,228 @@ +package harness + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + + "github.com/harness/harness-mcp/cmd/harness-mcp-server/config" + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" +) + +// FileChangeInput represents a file change for the combined create branch and commit operation +type FileChangeInput struct { + Action string `json:"action"` + Path string `json:"path"` + Content string `json:"content,omitempty"` +} + +// CreateBranchAndCommitRequest represents the request body for creating a branch and committing changes +type CreateBranchAndCommitRequest struct { + BranchName string `json:"branch_name"` + BaseBranch string `json:"base_branch"` + Title string `json:"title"` + Message string `json:"message,omitempty"` + FileChanges []FileChangeInput `json:"file_changes"` + BypassRules bool `json:"bypass_rules"` +} + +// CreateBranchAndCommitTool creates a tool for creating a branch and committing changes in one operation +func CreateBranchAndCommitTool(config *config.Config) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("create_branch_and_commit", + mcp.WithDescription("Create a branch and commit multiple file changes in a Harness repository."), + mcp.WithString("repo_identifier", + mcp.Required(), + mcp.Description("The identifier of the repository"), + ), + mcp.WithString("branch_name", + mcp.Required(), + mcp.Description("The name of the new branch to create"), + ), + mcp.WithString("base_branch", + mcp.DefaultString("master"), + mcp.Description("The base branch from which to create the new branch"), + ), + mcp.WithString("commit_title", + mcp.Required(), + mcp.Description("The title of the commit"), + ), + mcp.WithString("commit_message", + mcp.Description("Optional detailed commit message"), + ), + mcp.WithArray("file_changes", + mcp.Required(), + mcp.Description("Array of file changes to commit"), + ), + mcp.WithBoolean("bypass_rules", + mcp.Description("Whether to bypass branch protection rules"), + ), + WithScope(config, false), + ), + func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + repoIdentifier, err := requiredParam[string](request, "repo_identifier") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + branchName, err := requiredParam[string](request, "branch_name") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + baseBranch, err := OptionalParam[string](request, "base_branch") + if err != nil || baseBranch == "" { + baseBranch = "master" + } + + commitTitle, err := requiredParam[string](request, "commit_title") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + commitMessage, _ := OptionalParam[string](request, "commit_message") + bypassRules, _ := OptionalParam[bool](request, "bypass_rules") + + // Extract file changes + fileChangesRaw, err := requiredParam[interface{}](request, "file_changes") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + fileChangesArray, ok := fileChangesRaw.([]interface{}) + if !ok { + return mcp.NewToolResultError("file_changes must be an array"), nil + } + + if len(fileChangesArray) == 0 { + return mcp.NewToolResultError("file_changes cannot be empty"), nil + } + + // Convert file changes to the expected format + fileChanges := make([]FileChangeInput, 0, len(fileChangesArray)) + for _, changeRaw := range fileChangesArray { + changeMap, ok := changeRaw.(map[string]interface{}) + if !ok { + return mcp.NewToolResultError("each file change must be an object"), nil + } + + action, ok := changeMap["action"].(string) + if !ok || action == "" { + return mcp.NewToolResultError("action is required for each file change"), nil + } + + path, ok := changeMap["path"].(string) + if !ok || path == "" { + return mcp.NewToolResultError("path is required for each file change"), nil + } + + content, _ := changeMap["content"].(string) + + fileChanges = append(fileChanges, FileChangeInput{ + Action: action, + Path: path, + Content: content, + }) + } + + scope, err := fetchScope(config, request, false) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + // Step 1: Create the branch + createBranchRequestBody := map[string]interface{}{ + "name": branchName, + "target": fmt.Sprintf("refs/heads/%s", baseBranch), + "bypass_rules": bypassRules, + } + + branchURL := fmt.Sprintf("%sgateway/code/api/v1/repos/%s/%s/%s/%s/+/branches?routingId=%s", + config.BaseURL, config.AccountID, scope.OrgID, scope.ProjectID, repoIdentifier, config.AccountID) + + // Make HTTP request to create branch + branchResult, err := makeAPIRequest(ctx, http.MethodPost, branchURL, createBranchRequestBody, config.APIKey) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("failed to create branch: %s", err.Error())), nil + } + + // Step 2: Convert file changes to the format expected by commit_changes + commitFileChanges := make([]FileChange, 0, len(fileChanges)) + for _, change := range fileChanges { + commitFileChanges = append(commitFileChanges, FileChange{ + Action: change.Action, + Path: change.Path, + Payload: change.Content, + }) + } + + // Step 3: Commit the changes + commitRequest := CommitRequest{ + Actions: commitFileChanges, + Branch: branchName, + Title: commitTitle, + Message: commitMessage, + BypassRules: bypassRules, + } + + commitURL := fmt.Sprintf("%sgateway/code/api/v1/repos/%s/%s/%s/%s/+/commits?routingId=%s", + config.BaseURL, config.AccountID, scope.OrgID, scope.ProjectID, repoIdentifier, config.AccountID) + + // Make HTTP request to commit changes + commitResult, err := makeAPIRequest(ctx, http.MethodPost, commitURL, commitRequest, config.APIKey) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("failed to commit changes: %s", err.Error())), nil + } + + // Combine results + result := map[string]interface{}{ + "branch_creation": branchResult, + "commit_result": commitResult, + } + + resultBytes, err := json.Marshal(result) + if err != nil { + return nil, fmt.Errorf("failed to marshal combined result: %w", err) + } + + return mcp.NewToolResultText(string(resultBytes)), nil + } +} + +// makeAPIRequest is a helper function to make HTTP requests to the Harness API +func makeAPIRequest(ctx context.Context, method, url string, body interface{}, apiKey string) (map[string]interface{}, error) { + client := &http.Client{} + bodyBytes, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("failed to marshal request body: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, method, url, strings.NewReader(string(bodyBytes))) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + if apiKey != "" { + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", apiKey)) + } + + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to execute request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { + return nil, fmt.Errorf("request failed with status code %d", resp.StatusCode) + } + + var responseData map[string]interface{} + if err := json.NewDecoder(resp.Body).Decode(&responseData); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + return responseData, nil +} diff --git a/pkg/harness/file_content_commit.go b/pkg/harness/file_content_commit.go new file mode 100644 index 00000000..e73807cb --- /dev/null +++ b/pkg/harness/file_content_commit.go @@ -0,0 +1,76 @@ +package harness + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/harness/harness-mcp/client" + "github.com/harness/harness-mcp/client/dto" + "github.com/harness/harness-mcp/cmd/harness-mcp-server/config" + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" +) + +// GetFileContentFromCommitTool creates a tool for retrieving file content from a specific commit +func GetFileContentFromCommitTool(config *config.Config, client *client.RepositoryService) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("get_file_content_from_commit", + mcp.WithDescription("Get file content from a specific commit in a Harness repository."), + mcp.WithString("repo_identifier", + mcp.Required(), + mcp.Description("The identifier of the repository"), + ), + mcp.WithString("file_path", + mcp.Required(), + mcp.Description("The path to the file within the repository"), + ), + mcp.WithString("commit_id", + mcp.Required(), + mcp.Description("The commit ID (SHA) to retrieve the file from"), + ), + WithScope(config, false), + ), + func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + // Extract parameters + repoIdentifier, err := requiredParam[string](request, "repo_identifier") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + filePath, err := requiredParam[string](request, "file_path") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + commitID, err := requiredParam[string](request, "commit_id") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + // Get scope + scope, err := fetchScope(config, request, false) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + // Create request object + fileContentReq := &dto.FileContentRequest{ + Path: filePath, + GitRef: commitID, + } + + // Call client method + data, err := client.GetFileContent(ctx, scope, repoIdentifier, fileContentReq) + if err != nil { + return nil, fmt.Errorf("failed to get file content from commit: %w", err) + } + + // Marshal the result + r, err := json.Marshal(data) + if err != nil { + return nil, fmt.Errorf("failed to marshal file content: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } +} diff --git a/pkg/harness/repositories.go b/pkg/harness/repositories.go index a5b0fc9b..5ddd1f3c 100644 --- a/pkg/harness/repositories.go +++ b/pkg/harness/repositories.go @@ -2,6 +2,7 @@ package harness import ( "context" + "encoding/base64" "encoding/json" "fmt" @@ -134,3 +135,159 @@ func ListRepositoriesTool(config *config.Config, client *client.RepositoryServic return mcp.NewToolResultText(string(r)), nil } } + +// GetFileContentTool creates a tool for retrieving file content from a repository +func GetFileContentTool(config *config.Config, client *client.RepositoryService) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("get_file_content", + mcp.WithDescription("Get file content from a repository in Harness."), + mcp.WithString("repo_identifier", + mcp.Required(), + mcp.Description("The identifier of the repository"), + ), + mcp.WithString("file_path", + mcp.Required(), + mcp.Description("The path to the file within the repository"), + ), + mcp.WithString("git_ref", + mcp.Description("The git reference (branch, tag, or commit SHA). Can be a branch name like 'master', a tag, or a full commit SHA."), + ), + mcp.WithString("routing_id", + mcp.Description("Optional routing ID for the request. If not provided, the account ID will be used."), + ), + mcp.WithBoolean("decode_content", + mcp.DefaultBool(true), + mcp.Description("Whether to decode the base64-encoded content. Set to false to get the raw encoded content."), + ), + mcp.WithBoolean("include_metadata", + mcp.DefaultBool(true), + mcp.Description("Whether to include file metadata and commit information in the response. Set to false to get only the file content."), + ), + WithScope(config, false), + ), + func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + // Extract parameters + repoIdentifier, err := requiredParam[string](request, "repo_identifier") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + filePath, err := requiredParam[string](request, "file_path") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + gitRef, err := OptionalParam[string](request, "git_ref") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + routingID, err := OptionalParam[string](request, "routing_id") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + decodeContent, err := OptionalParam[bool](request, "decode_content") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + includeMetadata, err := OptionalParam[bool](request, "include_metadata") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + // Get scope + scope, err := fetchScope(config, request, false) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + // Create request object + fileContentReq := &dto.FileContentRequest{ + Path: filePath, + GitRef: gitRef, + RoutingID: routingID, + OrgID: scope.OrgID, + ProjectID: scope.ProjectID, + } + + // Call client method + data, err := client.GetFileContent(ctx, scope, repoIdentifier, fileContentReq) + if err != nil { + return nil, fmt.Errorf("failed to get file content: %w", err) + } + + // Handle case where the file doesn't exist or isn't a file + if data.Type != "file" || data.Content == nil { + return nil, fmt.Errorf("path does not point to a valid file or file content is empty") + } + + // If requested, decode the base64 content + if decodeContent && data.Content.Encoding == "base64" && data.Content.Data != "" { + decodedBytes, err := base64.StdEncoding.DecodeString(data.Content.Data) + if err != nil { + return nil, fmt.Errorf("failed to decode base64 content: %w", err) + } + + // If metadata is not requested, return only the decoded content + if !includeMetadata { + return mcp.NewToolResultText(string(decodedBytes)), nil + } + + // Create a response with both file metadata and decoded content + response := struct { + Type string `json:"type"` + Sha string `json:"sha"` + Name string `json:"name"` + Path string `json:"path"` + LatestCommit *dto.Commit `json:"latest_commit,omitempty"` + DecodedContent string `json:"decoded_content"` + ContentSize int `json:"content_size"` + }{ + Type: data.Type, + Sha: data.Sha, + Name: data.Name, + Path: data.Path, + LatestCommit: data.LatestCommit, + DecodedContent: string(decodedBytes), + ContentSize: len(decodedBytes), + } + + // Marshal the response + r, err := json.Marshal(response) + if err != nil { + return nil, fmt.Errorf("failed to marshal file content: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } + + // Return only the raw content if metadata is not requested + if !includeMetadata && data.Content != nil { + rawContent := struct { + Encoding string `json:"encoding"` + Data string `json:"data"` + Size int `json:"size"` + }{ + Encoding: data.Content.Encoding, + Data: data.Content.Data, + Size: data.Content.Size, + } + + r, err := json.Marshal(rawContent) + if err != nil { + return nil, fmt.Errorf("failed to marshal raw content: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } + + // Marshal the original response if no special handling was needed + r, err := json.Marshal(data) + if err != nil { + return nil, fmt.Errorf("failed to marshal file content: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } +} diff --git a/pkg/harness/tools.go b/pkg/harness/tools.go index fc355061..134b26f0 100644 --- a/pkg/harness/tools.go +++ b/pkg/harness/tools.go @@ -28,21 +28,24 @@ func InitToolsets(config *config.Config) (*toolsets.ToolsetGroup, error) { // Create a toolset group tsg := toolsets.NewToolsetGroup(config.ReadOnly) - // Register pipelines - if err := registerPipelines(config, tsg); err != nil { + // Register all tools + if err := registerRepositories(config, tsg); err != nil { return nil, err } - // TODO: support internal mode for other endpoints as well eventually - if err := registerPullRequests(config, tsg); err != nil { + if err := registerBranchOperations(config, tsg); err != nil { return nil, err } - if err := registerRepositories(config, tsg); err != nil { + if err := registerRegistries(config, tsg); err != nil { return nil, err } - if err := registerRegistries(config, tsg); err != nil { + if err := registerPipelines(config, tsg); err != nil { + return nil, err + } + + if err := registerPullRequests(config, tsg); err != nil { return nil, err } @@ -168,6 +171,9 @@ func registerRepositories(config *config.Config, tsg *toolsets.ToolsetGroup) err AddReadTools( toolsets.NewServerTool(GetRepositoryTool(config, repositoryClient)), toolsets.NewServerTool(ListRepositoriesTool(config, repositoryClient)), + toolsets.NewServerTool(GetFileContentTool(config, repositoryClient)), + toolsets.NewServerTool(GetFileContentFromCommitTool(config, repositoryClient)), + toolsets.NewServerTool(GetCommitDiffTool(config, c)), ) // Add toolset to the group @@ -175,6 +181,29 @@ func registerRepositories(config *config.Config, tsg *toolsets.ToolsetGroup) err return nil } +// registerBranchOperations registers the branch operations toolset +func registerBranchOperations(config *config.Config, tsg *toolsets.ToolsetGroup) error { + // Create a new toolset for branch operations + ts := toolsets.NewToolset("branchoperations", "Branch Operations") + + // Register the branch operation tools + createBranchTool, createBranchHandler := CreateBranchTool(config) + commitChangesTool, commitChangesHandler := CommitChangesTool(config) + createBranchAndCommitTool, createBranchAndCommitHandler := CreateBranchAndCommitTool(config) + + // Add the tools to the toolset + ts.AddWriteTools( + toolsets.NewServerTool(createBranchTool, createBranchHandler), + toolsets.NewServerTool(commitChangesTool, commitChangesHandler), + toolsets.NewServerTool(createBranchAndCommitTool, createBranchAndCommitHandler), + ) + + // Add the toolset to the toolset group + tsg.AddToolset(ts) + + return nil +} + // registerRegistries registers the registries toolset func registerRegistries(config *config.Config, tsg *toolsets.ToolsetGroup) error { // Determine the base URL and secret for registries diff --git a/test_commit_diff.go b/test_commit_diff.go new file mode 100644 index 00000000..6ad5f7e6 --- /dev/null +++ b/test_commit_diff.go @@ -0,0 +1,118 @@ +package main + +import ( + "context" + "flag" + "fmt" + "io" + "log" + "net/http" + "os" + + "github.com/harness/harness-mcp/cmd/harness-mcp-server/config" +) + +func main() { + // Define command line flags for all parameters + baseURLFlag := flag.String("base-url", "", "Harness API base URL (e.g., https://app.harness.io/)") + apiKeyFlag := flag.String("api-key", "", "Harness API key") + orgIDFlag := flag.String("org-id", "", "Organization ID") + projectIDFlag := flag.String("project-id", "", "Project ID") + accountIDFlag := flag.String("account-id", "", "Account ID") + repoIDFlag := flag.String("repo-id", "", "Repository identifier") + commitIDFlag := flag.String("commit-id", "", "Commit ID to get diff for") + + // Parse command line flags + flag.Parse() + + // Get values from environment variables if not provided via flags + baseURL := getValueWithFallback(*baseURLFlag, "HARNESS_BASE_URL", "https://app.harness.io/") + apiKey := getValueWithFallback(*apiKeyFlag, "HARNESS_API_KEY", "") + orgID := getValueWithFallback(*orgIDFlag, "HARNESS_ORG_ID", "default") + projectID := getValueWithFallback(*projectIDFlag, "HARNESS_PROJECT_ID", "") + accountID := getValueWithFallback(*accountIDFlag, "HARNESS_ACCOUNT_ID", "") + repoID := getValueWithFallback(*repoIDFlag, "HARNESS_REPO_ID", "") + commitID := getValueWithFallback(*commitIDFlag, "HARNESS_COMMIT_ID", "") + + // Validate required parameters + if apiKey == "" { + log.Fatal("API key is required. Provide it via --api-key flag or HARNESS_API_KEY environment variable") + } + if projectID == "" { + log.Fatal("Project ID is required. Provide it via --project-id flag or HARNESS_PROJECT_ID environment variable") + } + if accountID == "" { + log.Fatal("Account ID is required. Provide it via --account-id flag or HARNESS_ACCOUNT_ID environment variable") + } + if repoID == "" { + log.Fatal("Repository ID is required. Provide it via --repo-id flag or HARNESS_REPO_ID environment variable") + } + if commitID == "" { + log.Fatal("Commit ID is required. Provide it via --commit-id flag or HARNESS_COMMIT_ID environment variable") + } + + // Create a config for reference + cfg := &config.Config{ + BaseURL: baseURL, + APIKey: apiKey, + DefaultOrgID: orgID, + DefaultProjectID: projectID, + AccountID: accountID, + } + + // Make a direct API call to get commit diff using a custom HTTP client + // Since we're removing the handler approach, we'll use a simple HTTP request instead + // Create the URL for the API call + apiPath := fmt.Sprintf("%scode/api/v1/repos/%s/+/diff/%s", cfg.BaseURL, repoID, commitID) + + // Create HTTP request + req, err := http.NewRequestWithContext(context.Background(), "GET", apiPath, nil) + if err != nil { + log.Fatalf("Error creating request: %v", err) + } + + // Add query parameters + q := req.URL.Query() + q.Add("accountIdentifier", accountID) + q.Add("orgIdentifier", orgID) + q.Add("projectIdentifier", projectID) + req.URL.RawQuery = q.Encode() + + // Add headers + req.Header.Set("x-api-key", apiKey) + + // Create HTTP client and make the request + httpClient := &http.Client{} + resp, err := httpClient.Do(req) + if err != nil { + log.Fatalf("Error making request: %v", err) + } + defer resp.Body.Close() + + // Read and parse the response + body, err := io.ReadAll(resp.Body) + if err != nil { + log.Fatalf("Error reading response: %v", err) + } + + // Print the result + fmt.Println("Result:") + fmt.Println(string(body)) +} + +// getValueWithFallback returns the first non-empty value from: +// 1. The provided flag value +// 2. The environment variable with the given name +// 3. The default value +func getValueWithFallback(flagValue, envName, defaultValue string) string { + if flagValue != "" { + return flagValue + } + + envValue := os.Getenv(envName) + if envValue != "" { + return envValue + } + + return defaultValue +}