This is page 4 of 5. Use http://codebase.md/geropl/linear-mcp-go?page={x} to view the full context. # Directory Structure ``` ├── .clinerules │ └── memory-bank.md ├── .devcontainer │ ├── devcontainer.json │ └── Dockerfile ├── .github │ └── workflows │ └── release.yml ├── .gitignore ├── .gitpod.yml ├── cmd │ ├── root.go │ ├── serve.go │ ├── setup_test.go │ ├── setup.go │ └── version.go ├── docs │ ├── design │ │ ├── 001-mcp-go-upgrade.md │ │ └── 002-project-milestone-initiative.md │ └── prd │ ├── 000-tool-standardization-overview.md │ ├── 001-api-refresher.md │ ├── 002-tool-standardization.md │ ├── 003-tool-standardization-implementation.md │ ├── 004-tool-standardization-tracking.md │ ├── 005-sample-implementation.md │ ├── 006-issue-comments-pagination.md │ └── README.md ├── go.mod ├── go.sum ├── main.go ├── memory-bank │ ├── activeContext.md │ ├── developmentWorkflows.md │ ├── productContext.md │ ├── progress.md │ ├── projectbrief.md │ ├── systemPatterns.md │ └── techContext.md ├── pkg │ ├── linear │ │ ├── client.go │ │ ├── models.go │ │ ├── rate_limiter.go │ │ └── test_helpers.go │ ├── server │ │ ├── resources_test.go │ │ ├── resources.go │ │ ├── server.go │ │ ├── test_helpers.go │ │ └── tools_test.go │ └── tools │ ├── add_comment.go │ ├── common.go │ ├── create_issue.go │ ├── get_issue_comments.go │ ├── get_issue.go │ ├── get_teams.go │ ├── get_user_issues.go │ ├── initiative_tools.go │ ├── milestone_tools.go │ ├── priority_test.go │ ├── priority.go │ ├── project_tools.go │ ├── rendering.go │ ├── reply_to_comment.go │ ├── search_issues.go │ ├── update_issue_comment.go │ └── update_issue.go ├── README.md ├── scripts │ └── register-cline.sh └── testdata ├── fixtures │ ├── add_comment_handler_Missing body.yaml │ ├── add_comment_handler_Missing issue.yaml │ ├── add_comment_handler_Missing issueId.yaml │ ├── add_comment_handler_Reply with shorthand.yaml │ ├── add_comment_handler_Reply with URL.yaml │ ├── add_comment_handler_Reply_to_comment.yaml │ ├── add_comment_handler_Valid comment.yaml │ ├── create_initiative_handler_Missing name.yaml │ ├── create_initiative_handler_Valid initiative.yaml │ ├── create_initiative_handler_With description.yaml │ ├── create_issue_handler_Create issue with invalid project.yaml │ ├── create_issue_handler_Create issue with labels.yaml │ ├── create_issue_handler_Create issue with project ID.yaml │ ├── create_issue_handler_Create issue with project name.yaml │ ├── create_issue_handler_Create issue with project slug.yaml │ ├── create_issue_handler_Create sub issue from identifier.yaml │ ├── create_issue_handler_Create sub issue with labels.yaml │ ├── create_issue_handler_Create sub issue.yaml │ ├── create_issue_handler_Invalid team.yaml │ ├── create_issue_handler_Missing team.yaml │ ├── create_issue_handler_Missing teamId.yaml │ ├── create_issue_handler_Missing title.yaml │ ├── create_issue_handler_Valid issue with team key.yaml │ ├── create_issue_handler_Valid issue with team name.yaml │ ├── create_issue_handler_Valid issue with team UUID.yaml │ ├── create_issue_handler_Valid issue with team.yaml │ ├── create_issue_handler_Valid issue with teamId.yaml │ ├── create_issue_handler_Valid issue.yaml │ ├── create_milestone_handler_Invalid project ID.yaml │ ├── create_milestone_handler_Missing name.yaml │ ├── create_milestone_handler_Valid milestone.yaml │ ├── create_milestone_handler_With all optional fields.yaml │ ├── create_project_handler_Invalid team ID.yaml │ ├── create_project_handler_Missing name.yaml │ ├── create_project_handler_Valid project.yaml │ ├── create_project_handler_With all optional fields.yaml │ ├── get_initiative_handler_By name.yaml │ ├── get_initiative_handler_Non-existent name.yaml │ ├── get_initiative_handler_Valid initiative.yaml │ ├── get_issue_comments_handler_Invalid issue.yaml │ ├── get_issue_comments_handler_Missing issue.yaml │ ├── get_issue_comments_handler_Thread_with_pagination.yaml │ ├── get_issue_comments_handler_Valid issue.yaml │ ├── get_issue_comments_handler_With limit.yaml │ ├── get_issue_comments_handler_With_thread_parameter.yaml │ ├── get_issue_handler_Get comment issue.yaml │ ├── get_issue_handler_Missing issue.yaml │ ├── get_issue_handler_Missing issueId.yaml │ ├── get_issue_handler_Valid issue.yaml │ ├── get_milestone_handler_By name.yaml │ ├── get_milestone_handler_Non-existent milestone.yaml │ ├── get_milestone_handler_Valid milestone.yaml │ ├── get_project_handler_By ID.yaml │ ├── get_project_handler_By name.yaml │ ├── get_project_handler_By slug.yaml │ ├── get_project_handler_Invalid project.yaml │ ├── get_project_handler_Missing project param.yaml │ ├── get_project_handler_Non-existent slug.yaml │ ├── get_teams_handler_Get Teams.yaml │ ├── get_user_issues_handler_Current user issues.yaml │ ├── get_user_issues_handler_Specific user issues.yaml │ ├── reply_to_comment_handler_Missing body.yaml │ ├── reply_to_comment_handler_Missing thread.yaml │ ├── reply_to_comment_handler_Reply with URL.yaml │ ├── reply_to_comment_handler_Valid reply.yaml │ ├── resource_TeamResourceHandler_Fetch By ID.yaml │ ├── resource_TeamResourceHandler_Fetch By Key.yaml │ ├── resource_TeamResourceHandler_Fetch By Name.yaml │ ├── resource_TeamResourceHandler_Invalid ID.yaml │ ├── resource_TeamResourceHandler_Missing ID.yaml │ ├── resource_TeamsResourceHandler_List All.yaml │ ├── search_issues_handler_Search by query.yaml │ ├── search_issues_handler_Search by team.yaml │ ├── search_projects_handler_Empty query.yaml │ ├── search_projects_handler_Multiple results.yaml │ ├── search_projects_handler_No results.yaml │ ├── search_projects_handler_Search by query.yaml │ ├── update_comment_handler_Invalid comment identifier.yaml │ ├── update_comment_handler_Missing body.yaml │ ├── update_comment_handler_Missing comment.yaml │ ├── update_comment_handler_Valid comment update with hash only.yaml │ ├── update_comment_handler_Valid comment update with shorthand.yaml │ ├── update_comment_handler_Valid comment update.yaml │ ├── update_initiative_handler_Non-existent initiative.yaml │ ├── update_initiative_handler_Valid update.yaml │ ├── update_issue_handler_Missing id.yaml │ ├── update_issue_handler_Valid update.yaml │ ├── update_milestone_handler_Non-existent milestone.yaml │ ├── update_milestone_handler_Valid update.yaml │ ├── update_project_handler_Non-existent project.yaml │ ├── update_project_handler_Update name and description.yaml │ ├── update_project_handler_Update only description.yaml │ └── update_project_handler_Valid update.yaml └── golden ├── add_comment_handler_Missing body.golden ├── add_comment_handler_Missing issue.golden ├── add_comment_handler_Missing issueId.golden ├── add_comment_handler_Reply with shorthand.golden ├── add_comment_handler_Reply with URL.golden ├── add_comment_handler_Reply_to_comment.golden ├── add_comment_handler_Valid comment.golden ├── create_initiative_handler_Missing name.golden ├── create_initiative_handler_Valid initiative.golden ├── create_initiative_handler_With description.golden ├── create_issue_handler_Create issue with invalid project.golden ├── create_issue_handler_Create issue with labels.golden ├── create_issue_handler_Create issue with project ID.golden ├── create_issue_handler_Create issue with project name.golden ├── create_issue_handler_Create issue with project slug.golden ├── create_issue_handler_Create sub issue from identifier.golden ├── create_issue_handler_Create sub issue with labels.golden ├── create_issue_handler_Create sub issue.golden ├── create_issue_handler_Invalid team.golden ├── create_issue_handler_Missing team.golden ├── create_issue_handler_Missing teamId.golden ├── create_issue_handler_Missing title.golden ├── create_issue_handler_Valid issue with team key.golden ├── create_issue_handler_Valid issue with team name.golden ├── create_issue_handler_Valid issue with team UUID.golden ├── create_issue_handler_Valid issue with team.golden ├── create_issue_handler_Valid issue with teamId.golden ├── create_issue_handler_Valid issue.golden ├── create_milestone_handler_Invalid project ID.golden ├── create_milestone_handler_Missing name.golden ├── create_milestone_handler_Valid milestone.golden ├── create_milestone_handler_With all optional fields.golden ├── create_project_handler_Invalid team ID.golden ├── create_project_handler_Missing name.golden ├── create_project_handler_Valid project.golden ├── create_project_handler_With all optional fields.golden ├── get_initiative_handler_By name.golden ├── get_initiative_handler_Non-existent name.golden ├── get_initiative_handler_Valid initiative.golden ├── get_issue_comments_handler_Invalid issue.golden ├── get_issue_comments_handler_Missing issue.golden ├── get_issue_comments_handler_Thread_with_pagination.golden ├── get_issue_comments_handler_Valid issue.golden ├── get_issue_comments_handler_With limit.golden ├── get_issue_comments_handler_With_thread_parameter.golden ├── get_issue_handler_Get comment issue.golden ├── get_issue_handler_Missing issue.golden ├── get_issue_handler_Missing issueId.golden ├── get_issue_handler_Valid issue.golden ├── get_milestone_handler_By name.golden ├── get_milestone_handler_Non-existent milestone.golden ├── get_milestone_handler_Valid milestone.golden ├── get_project_handler_By ID.golden ├── get_project_handler_By name.golden ├── get_project_handler_By slug.golden ├── get_project_handler_Invalid project.golden ├── get_project_handler_Missing project param.golden ├── get_project_handler_Non-existent slug.golden ├── get_teams_handler_Get Teams.golden ├── get_user_issues_handler_Current user issues.golden ├── get_user_issues_handler_Specific user issues.golden ├── reply_to_comment_handler_Missing body.golden ├── reply_to_comment_handler_Missing thread.golden ├── reply_to_comment_handler_Reply with URL.golden ├── reply_to_comment_handler_Valid reply.golden ├── resource_TeamResourceHandler_Fetch By ID.golden ├── resource_TeamResourceHandler_Fetch By Key.golden ├── resource_TeamResourceHandler_Fetch By Name.golden ├── resource_TeamResourceHandler_Invalid ID.golden ├── resource_TeamResourceHandler_Missing ID.golden ├── resource_TeamsResourceHandler_List All.golden ├── search_issues_handler_Search by query.golden ├── search_issues_handler_Search by team.golden ├── search_projects_handler_Empty query.golden ├── search_projects_handler_Multiple results.golden ├── search_projects_handler_No results.golden ├── search_projects_handler_Search by query.golden ├── update_comment_handler_Invalid comment identifier.golden ├── update_comment_handler_Missing body.golden ├── update_comment_handler_Missing comment.golden ├── update_comment_handler_Valid comment update with hash only.golden ├── update_comment_handler_Valid comment update with shorthand.golden ├── update_comment_handler_Valid comment update.golden ├── update_initiative_handler_Non-existent initiative.golden ├── update_initiative_handler_Valid update.golden ├── update_issue_handler_Missing id.golden ├── update_issue_handler_Valid update.golden ├── update_milestone_handler_Non-existent milestone.golden ├── update_milestone_handler_Valid update.golden ├── update_project_handler_Non-existent project.golden ├── update_project_handler_Update name and description.golden ├── update_project_handler_Update only description.golden └── update_project_handler_Valid update.golden ``` # Files -------------------------------------------------------------------------------- /pkg/linear/models.go: -------------------------------------------------------------------------------- ```go package linear import "time" // Issue represents a Linear issue type Issue struct { ID string `json:"id"` Identifier string `json:"identifier"` Title string `json:"title"` Description string `json:"description"` Priority int `json:"priority"` Status string `json:"status"` Assignee *User `json:"assignee,omitempty"` Team *Team `json:"team,omitempty"` Project *Project `json:"project,omitempty"` ProjectMilestone *ProjectMilestone `json:"projectMilestone,omitempty"` URL string `json:"url"` CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"` Labels *LabelConnection `json:"labels,omitempty"` State *State `json:"state,omitempty"` Estimate *float64 `json:"estimate,omitempty"` Comments *CommentConnection `json:"comments,omitempty"` Relations *IssueRelationConnection `json:"relations,omitempty"` InverseRelations *IssueRelationConnection `json:"inverseRelations,omitempty"` Attachments *AttachmentConnection `json:"attachments,omitempty"` } // User represents a Linear user type User struct { ID string `json:"id"` Name string `json:"name"` Email string `json:"email"` Admin bool `json:"admin"` } // Team represents a Linear team type Team struct { ID string `json:"id"` Name string `json:"name"` Key string `json:"key"` } // Project represents a Linear project type Project struct { ID string `json:"id"` Name string `json:"name"` Description string `json:"description"` SlugID string `json:"slugId"` State string `json:"state"` Creator *User `json:"creator,omitempty"` Lead *User `json:"lead,omitempty"` // Members *UserConnection `json:"members,omitempty"` // Teams *TeamConnection `json:"teams,omitempty"` Initiatives *InitiativeConnection `json:"initiatives,omitempty"` StartDate *string `json:"startDate,omitempty"` TargetDate *string `json:"targetDate,omitempty"` Color string `json:"color"` Icon string `json:"icon,omitempty"` URL string `json:"url"` } // ProjectConnection represents a connection of projects type ProjectConnection struct { Nodes []Project `json:"nodes"` } // ProjectMilestoneConnection represents a connection of project milestones. type ProjectMilestoneConnection struct { Nodes []ProjectMilestone `json:"nodes"` } // ProjectMilestone represents a Linear project milestone type ProjectMilestone struct { ID string `json:"id"` Name string `json:"name"` Description string `json:"description,omitempty"` TargetDate *string `json:"targetDate,omitempty"` Project *Project `json:"project,omitempty"` SortOrder float64 `json:"sortOrder"` } // InitiativeConnection represents a connection of initiatives. type InitiativeConnection struct { Nodes []Initiative `json:"nodes"` } // Initiative represents a Linear initiative type Initiative struct { ID string `json:"id"` Name string `json:"name"` Description string `json:"description,omitempty"` Owner *User `json:"owner,omitempty"` Color string `json:"color,omitempty"` Icon string `json:"icon,omitempty"` SlugID string `json:"slugId"` URL string `json:"url"` } // State represents a workflow state in Linear type State struct { ID string `json:"id"` Name string `json:"name"` } // LabelConnection represents a connection of labels type LabelConnection struct { Nodes []Label `json:"nodes"` } // Label represents a Linear issue label type Label struct { ID string `json:"id"` Name string `json:"name"` } // CommentConnection represents a connection of comments type CommentConnection struct { Nodes []Comment `json:"nodes"` } // PageInfo represents pagination information type PageInfo struct { HasNextPage bool `json:"hasNextPage"` EndCursor string `json:"endCursor"` } // PaginatedCommentConnection represents a paginated connection of comments type PaginatedCommentConnection struct { Nodes []Comment `json:"nodes"` PageInfo PageInfo `json:"pageInfo"` } // Comment represents a comment on a Linear issue type Comment struct { ID string `json:"id"` Body string `json:"body"` User *User `json:"user,omitempty"` CreatedAt time.Time `json:"createdAt"` URL string `json:"url,omitempty"` Parent *Comment `json:"parent,omitempty"` Children *CommentConnection `json:"children,omitempty"` Issue *Issue `json:"issue,omitempty"` } // IssueRelationConnection represents a connection of issue relations type IssueRelationConnection struct { Nodes []IssueRelation `json:"nodes"` } // IssueRelation represents a relation between two issues type IssueRelation struct { ID string `json:"id"` Type string `json:"type"` RelatedIssue *Issue `json:"relatedIssue,omitempty"` Issue *Issue `json:"issue,omitempty"` } // AttachmentConnection represents a connection of attachments type AttachmentConnection struct { Nodes []Attachment `json:"nodes"` } // Attachment represents an external resource linked to an issue type Attachment struct { ID string `json:"id"` Title string `json:"title"` Subtitle string `json:"subtitle,omitempty"` URL string `json:"url"` SourceType string `json:"sourceType,omitempty"` Metadata map[string]interface{} `json:"metadata,omitempty"` CreatedAt time.Time `json:"createdAt"` } // Organization represents a Linear organization type Organization struct { ID string `json:"id"` Name string `json:"name"` URLKey string `json:"urlKey"` Teams []Team `json:"teams,omitempty"` Users []User `json:"users,omitempty"` } // LinearIssueResponse represents a simplified issue response type LinearIssueResponse struct { ID string `json:"id"` Identifier string `json:"identifier"` Title string `json:"title"` Priority int `json:"priority"` Status string `json:"status,omitempty"` StateName string `json:"stateName,omitempty"` URL string `json:"url"` Project *Project `json:"project,omitempty"` ProjectMilestone *ProjectMilestone `json:"projectMilestone,omitempty"` } // APIMetrics represents metrics about API usage type APIMetrics struct { RequestsInLastHour int `json:"requestsInLastHour"` RemainingRequests int `json:"remainingRequests"` AverageRequestTime string `json:"averageRequestTime"` QueueLength int `json:"queueLength"` LastRequestTime string `json:"lastRequestTime"` } // CreateIssueInput represents input for creating an issue type CreateIssueInput struct { Title string `json:"title"` TeamID string `json:"teamId"` Description string `json:"description,omitempty"` Priority *int `json:"priority,omitempty"` Status string `json:"status,omitempty"` ParentID *string `json:"parentId,omitempty"` LabelIDs []string `json:"labelIds,omitempty"` ProjectID string `json:"projectId,omitempty"` } // UpdateIssueInput represents input for updating an issue type UpdateIssueInput struct { ID string `json:"id"` Title string `json:"title,omitempty"` Description string `json:"description,omitempty"` Priority *int `json:"priority,omitempty"` Status string `json:"status,omitempty"` TeamID string `json:"teamId,omitempty"` ProjectID string `json:"projectId,omitempty"` MilestoneID string `json:"milestoneId,omitempty"` } // SearchIssuesInput represents input for searching issues type SearchIssuesInput struct { Query string `json:"query,omitempty"` TeamID string `json:"teamId,omitempty"` Status string `json:"status,omitempty"` AssigneeID string `json:"assigneeId,omitempty"` Labels []string `json:"labels,omitempty"` Priority *int `json:"priority,omitempty"` Estimate *float64 `json:"estimate,omitempty"` IncludeArchived bool `json:"includeArchived,omitempty"` Limit int `json:"limit,omitempty"` } // GetUserIssuesInput represents input for getting user issues type GetUserIssuesInput struct { UserID string `json:"userId,omitempty"` IncludeArchived bool `json:"includeArchived,omitempty"` Limit int `json:"limit,omitempty"` } // GetIssueCommentsInput represents input for getting issue comments type GetIssueCommentsInput struct { IssueID string `json:"issueId"` ParentID string `json:"parentId,omitempty"` Limit int `json:"limit,omitempty"` AfterCursor string `json:"afterCursor,omitempty"` } // AddCommentInput represents input for adding a comment type AddCommentInput struct { IssueID string `json:"issueId"` Body string `json:"body"` CreateAsUser string `json:"createAsUser,omitempty"` ParentID string `json:"parentId,omitempty"` } // UpdateCommentInput represents the input for updating a comment type UpdateCommentInput struct { CommentID string `json:"commentId"` Body string `json:"body"` } // ProjectCreateInput represents the input for creating a project. type ProjectCreateInput struct { Name string `json:"name"` TeamIDs []string `json:"teamIds"` Description string `json:"description,omitempty"` LeadID string `json:"leadId,omitempty"` StartDate string `json:"startDate,omitempty"` TargetDate string `json:"targetDate,omitempty"` } // ProjectUpdateInput represents the input for updating a project. type ProjectUpdateInput struct { Name string `json:"name,omitempty"` Description string `json:"description,omitempty"` LeadID string `json:"leadId,omitempty"` StartDate string `json:"startDate,omitempty"` TargetDate string `json:"targetDate,omitempty"` TeamIDs []string `json:"teamIds,omitempty"` } // ProjectMilestoneCreateInput represents the input for creating a project milestone. type ProjectMilestoneCreateInput struct { Name string `json:"name"` ProjectID string `json:"projectId"` Description string `json:"description,omitempty"` TargetDate string `json:"targetDate,omitempty"` } // ProjectMilestoneUpdateInput represents the input for updating a project milestone. type ProjectMilestoneUpdateInput struct { Name string `json:"name,omitempty"` Description string `json:"description,omitempty"` TargetDate string `json:"targetDate,omitempty"` } // InitiativeCreateInput represents the input for creating an initiative. type InitiativeCreateInput struct { Name string `json:"name"` Description string `json:"description,omitempty"` } // InitiativeUpdateInput represents the input for updating an initiative. type InitiativeUpdateInput struct { Name string `json:"name,omitempty"` Description string `json:"description,omitempty"` } // GraphQLRequest represents a GraphQL request type GraphQLRequest struct { Query string `json:"query"` Variables map[string]interface{} `json:"variables,omitempty"` } // GraphQLResponse represents a GraphQL response type GraphQLResponse struct { Data map[string]interface{} `json:"data,omitempty"` Errors []GraphQLError `json:"errors,omitempty"` } // GraphQLError represents a GraphQL error type GraphQLError struct { Message string `json:"message"` } ``` -------------------------------------------------------------------------------- /docs/prd/005-sample-implementation.md: -------------------------------------------------------------------------------- ```markdown # Sample Implementation This document provides sample implementations for the key components of the tool standardization effort. These samples can be used as references when implementing the actual changes. ## 1. Shared Utility Functions ### rendering.go ```go package tools import ( "fmt" "strings" "github.com/geropl/linear-mcp-go/pkg/linear" ) // Full Entity Rendering Functions // formatIssue returns a consistently formatted full representation of an issue func formatIssue(issue *linear.Issue) string { if issue == nil { return "Issue: Unknown" } var result strings.Builder result.WriteString(fmt.Sprintf("Issue: %s (UUID: %s)\n", issue.Identifier, issue.ID)) result.WriteString(fmt.Sprintf("Title: %s\n", issue.Title)) result.WriteString(fmt.Sprintf("URL: %s\n", issue.URL)) if issue.Description != "" { result.WriteString(fmt.Sprintf("Description: %s\n", issue.Description)) } priorityStr := "None" if issue.Priority > 0 { priorityStr = fmt.Sprintf("%d", issue.Priority) } result.WriteString(fmt.Sprintf("Priority: %s\n", priorityStr)) statusStr := "None" if issue.Status != "" { statusStr = issue.Status } else if issue.State != nil { statusStr = issue.State.Name } result.WriteString(fmt.Sprintf("Status: %s\n", statusStr)) if issue.Assignee != nil { result.WriteString(fmt.Sprintf("Assignee: %s\n", formatUserIdentifier(issue.Assignee))) } if issue.Team != nil { result.WriteString(fmt.Sprintf("Team: %s\n", formatTeamIdentifier(issue.Team))) } return result.String() } // formatTeam returns a consistently formatted full representation of a team func formatTeam(team *linear.Team) string { if team == nil { return "Team: Unknown" } var result strings.Builder result.WriteString(fmt.Sprintf("Team: %s (UUID: %s)\n", team.Name, team.ID)) result.WriteString(fmt.Sprintf("Key: %s\n", team.Key)) return result.String() } // formatUser returns a consistently formatted full representation of a user func formatUser(user *linear.User) string { if user == nil { return "User: Unknown" } var result strings.Builder result.WriteString(fmt.Sprintf("User: %s (UUID: %s)\n", user.Name, user.ID)) if user.Email != "" { result.WriteString(fmt.Sprintf("Email: %s\n", user.Email)) } return result.String() } // formatComment returns a consistently formatted full representation of a comment func formatComment(comment *linear.Comment) string { if comment == nil { return "Comment: Unknown" } userName := "Unknown" if comment.User != nil { userName = comment.User.Name } var result strings.Builder result.WriteString(fmt.Sprintf("Comment by %s (UUID: %s)\n", userName, comment.ID)) result.WriteString(fmt.Sprintf("Body: %s\n", comment.Body)) if comment.CreatedAt != nil { result.WriteString(fmt.Sprintf("Created: %s\n", comment.CreatedAt.Format("2006-01-02 15:04:05"))) } return result.String() } // Entity Identifier Rendering Functions // formatIssueIdentifier returns a consistently formatted identifier for an issue func formatIssueIdentifier(issue *linear.Issue) string { if issue == nil { return "Issue: Unknown" } return fmt.Sprintf("Issue: %s (UUID: %s)", issue.Identifier, issue.ID) } // formatTeamIdentifier returns a consistently formatted identifier for a team func formatTeamIdentifier(team *linear.Team) string { if team == nil { return "Team: Unknown" } return fmt.Sprintf("Team: %s (UUID: %s)", team.Name, team.ID) } // formatUserIdentifier returns a consistently formatted identifier for a user func formatUserIdentifier(user *linear.User) string { if user == nil { return "User: Unknown" } return fmt.Sprintf("User: %s (UUID: %s)", user.Name, user.ID) } // formatCommentIdentifier returns a consistently formatted identifier for a comment func formatCommentIdentifier(comment *linear.Comment) string { if comment == nil { return "Comment: Unknown" } userName := "Unknown" if comment.User != nil { userName = comment.User.Name } return fmt.Sprintf("Comment by %s (UUID: %s)", userName, comment.ID) } ``` ### Updated common.go ```go // resolveIssueIdentifier resolves an issue identifier (UUID or "TEAM-123") to a UUID func resolveIssueIdentifier(linearClient *linear.LinearClient, identifier string) (string, error) { // If it's a valid UUID, use it directly if isValidUUID(identifier) { return identifier, nil } // Otherwise, try to find an issue by identifier issue, err := linearClient.GetIssueByIdentifier(identifier) if err != nil { return "", fmt.Errorf("failed to resolve issue identifier '%s': %v", identifier, err) } return issue.ID, nil } // resolveUserIdentifier resolves a user identifier (UUID, name, or email) to a UUID func resolveUserIdentifier(linearClient *linear.LinearClient, identifier string) (string, error) { // If it's a valid UUID, use it directly if isValidUUID(identifier) { return identifier, nil } // Otherwise, try to find a user by name or email users, err := linearClient.GetUsers() if err != nil { return "", fmt.Errorf("failed to get users: %v", err) } // First try exact match on name or email for _, user := range users { if user.Name == identifier || user.Email == identifier { return user.ID, nil } } // If no exact match, try case-insensitive match identifierLower := strings.ToLower(identifier) for _, user := range users { if strings.ToLower(user.Name) == identifierLower || strings.ToLower(user.Email) == identifierLower { return user.ID, nil } } return "", fmt.Errorf("no user found with identifier '%s'", identifier) } ``` ## 2. Updated Tool Examples ### linear_create_issue ```go // Before var CreateIssueTool = mcp.NewTool("linear_create_issue", mcp.WithDescription("Creates a new Linear issue with specified details. Use this to create tickets for tasks, bugs, or feature requests. Returns the created issue's identifier and URL. Supports creating sub-issues and assigning labels."), // ... parameters ... ) // After var CreateIssueTool = mcp.NewTool("linear_create_issue", mcp.WithDescription("Creates a new Linear issue."), // ... parameters ... ) // Before (result formatting) resultText := fmt.Sprintf("Created issue: %s\nTitle: %s\nURL: %s", issue.Identifier, issue.Title, issue.URL) // After (result formatting) resultText := fmt.Sprintf("Created %s\nTitle: %s\nURL: %s", formatIssue(issue), issue.Title, issue.URL) ``` ### linear_update_issue ```go // Before var UpdateIssueTool = mcp.NewTool("linear_update_issue", mcp.WithDescription("Updates an existing Linear issue's properties. Use this to modify issue details like title, description, priority, or status. Requires the issue ID and accepts any combination of updatable fields. Returns the updated issue's identifier and URL."), // ... parameters ... ) // After var UpdateIssueTool = mcp.NewTool("linear_update_issue", mcp.WithDescription("Updates an existing Linear issue."), // ... parameters ... ) // Before (parameter handling) id, ok := args["id"].(string) if !ok || id == "" { return mcp.NewToolResultError("id must be a non-empty string"), nil } // After (parameter handling) issueID, ok := args["id"].(string) if !ok || issueID == "" { return mcp.NewToolResultError("id must be a non-empty string"), nil } // Resolve the issue identifier id, err := resolveIssueIdentifier(linearClient, issueID) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Failed to resolve issue: %v", err)), nil } // Before (result formatting) resultText := fmt.Sprintf("Updated issue %s\nURL: %s", issue.Identifier, issue.URL) // After (result formatting) resultText := fmt.Sprintf("Updated %s\nURL: %s", formatIssue(issue), issue.URL) ``` ### linear_get_issue ```go // Before var GetIssueTool = mcp.NewTool("linear_get_issue", mcp.WithDescription("Retrieves a single Linear issue by its ID. Returns detailed information about the issue including title, description, priority, status, assignee, team, full comment history (including nested comments), related issues, and all attachments (pull requests, design files, documents, etc.)."), // ... parameters ... ) // After var GetIssueTool = mcp.NewTool("linear_get_issue", mcp.WithDescription("Retrieves a single Linear issue."), // ... parameters ... ) // Before (parameter handling) issueID, ok := args["issueId"].(string) if !ok || issueID == "" { return mcp.NewToolResultError("issueId must be a non-empty string"), nil } // After (parameter handling) issueIdentifier, ok := args["issueId"].(string) if !ok || issueIdentifier == "" { return mcp.NewToolResultError("issueId must be a non-empty string"), nil } // Resolve the issue identifier issueID, err := resolveIssueIdentifier(linearClient, issueIdentifier) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Failed to resolve issue: %v", err)), nil } // Before (result formatting) resultText := fmt.Sprintf("Issue %s: %s\n", issue.Identifier, issue.Title) // ... more formatting ... // After (result formatting) // Use the full formatIssue function for the main entity resultText := formatIssue(issue) // When referencing related entities, use the identifier formatting functions if issue.Assignee != nil { resultText += fmt.Sprintf("Assignee: %s\n", formatUserIdentifier(issue.Assignee)) } if issue.Team != nil { resultText += fmt.Sprintf("Team: %s\n", formatTeamIdentifier(issue.Team)) } // For related issues, use the identifier formatting if issue.Relations != nil && len(issue.Relations.Nodes) > 0 { resultText += "\nRelated Issues:\n" for _, relation := range issue.Relations.Nodes { if relation.RelatedIssue != nil { resultText += fmt.Sprintf("- %s\n RelationType: %s\n", formatIssueIdentifier(relation.RelatedIssue), relation.Type) } } } ``` ## 3. Testing Examples ### Test for resolveIssueIdentifier ```go func TestResolveIssueIdentifier(t *testing.T) { // Create test client client, cleanup := linear.NewTestClient(t, "resolve_issue_identifier", true) defer cleanup() // Test cases tests := []struct { name string identifier string wantErr bool }{ { name: "Valid UUID", identifier: "1c2de93f-4321-4015-bfde-ee893ef7976f", wantErr: false, }, { name: "Valid identifier", identifier: "TEST-10", wantErr: false, }, { name: "Invalid identifier", identifier: "NONEXISTENT-123", wantErr: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, err := resolveIssueIdentifier(client, tt.identifier) if (err != nil) != tt.wantErr { t.Errorf("resolveIssueIdentifier() error = %v, wantErr %v", err, tt.wantErr) return } if !tt.wantErr && got == "" { t.Errorf("resolveIssueIdentifier() returned empty UUID") } }) } } ``` ### Test for Formatting Functions ```go func TestFormatIssue(t *testing.T) { tests := []struct { name string issue *linear.Issue want string }{ { name: "Valid issue", issue: &linear.Issue{ ID: "1c2de93f-4321-4015-bfde-ee893ef7976f", Identifier: "TEST-10", }, want: "Issue: TEST-10 (UUID: 1c2de93f-4321-4015-bfde-ee893ef7976f)", }, { name: "Nil issue", issue: nil, want: "Issue: Unknown", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := formatIssue(tt.issue); got != tt.want { t.Errorf("formatIssue() = %v, want %v", got, tt.want) } }) } } ``` ## 4. Implementation Strategy 1. Start with creating the shared utility functions in `rendering.go` and updating `common.go` 2. Implement the changes for one tool (e.g., `linear_create_issue`) as a reference 3. Review the reference implementation to ensure it meets all requirements 4. Apply the same patterns to all remaining tools 5. Update tests to verify the changes ## 5. Potential Challenges and Solutions ### Challenge: Backward Compatibility Changing the output format of tools could break existing integrations that parse the output. **Solution:** Consider versioning the API or providing a compatibility mode. ### Challenge: Test Fixtures Updating the output format will require updating all test fixtures. **Solution:** Use the `--golden` flag to update all golden files at once after implementing the changes. ### Challenge: Consistent Implementation Ensuring consistency across all tools can be challenging. **Solution:** Create a code review checklist to verify that each tool follows the same patterns. ``` -------------------------------------------------------------------------------- /docs/prd/003-tool-standardization-implementation.md: -------------------------------------------------------------------------------- ```markdown # Tool Standardization Implementation Guide This document provides a detailed implementation plan for standardizing the Linear MCP Server tools according to the requirements outlined in [002-tool-standardization.md](./002-tool-standardization.md). ## Implementation Approach We'll implement the standardization in phases, focusing on one rule at a time across all tools to ensure consistency: 1. First, create the necessary shared utility functions 2. Then, update each tool one by one, applying all three rules 3. Finally, update tests to verify the changes ## Shared Utility Functions ### 1. Identifier Resolution Functions Create or update the following functions in `pkg/tools/common.go`: ```go // resolveIssueIdentifier resolves an issue identifier (UUID or "TEAM-123") to a UUID // This is an extension of the existing resolveParentIssueIdentifier function func resolveIssueIdentifier(linearClient *linear.LinearClient, identifier string) (string, error) { // Implementation similar to resolveParentIssueIdentifier } // resolveUserIdentifier resolves a user identifier (UUID, name, or email) to a UUID func resolveUserIdentifier(linearClient *linear.LinearClient, identifier string) (string, error) { // Implementation } ``` ### 2. Entity Rendering Functions Create a new file `pkg/tools/rendering.go` with two types of formatting functions: #### Full Entity Rendering Functions ```go // formatIssue returns a consistently formatted full representation of an issue func formatIssue(issue *linear.Issue) string { if issue == nil { return "Issue: Unknown" } var result strings.Builder result.WriteString(fmt.Sprintf("Issue: %s (UUID: %s)\n", issue.Identifier, issue.ID)) result.WriteString(fmt.Sprintf("Title: %s\n", issue.Title)) result.WriteString(fmt.Sprintf("URL: %s\n", issue.URL)) // Add other required fields return result.String() } // formatTeam returns a consistently formatted full representation of a team func formatTeam(team *linear.Team) string { if team == nil { return "Team: Unknown" } var result strings.Builder result.WriteString(fmt.Sprintf("Team: %s (UUID: %s)\n", team.Name, team.ID)) result.WriteString(fmt.Sprintf("Key: %s\n", team.Key)) // Add other required fields return result.String() } // formatUser returns a consistently formatted full representation of a user func formatUser(user *linear.User) string { if user == nil { return "User: Unknown" } var result strings.Builder result.WriteString(fmt.Sprintf("User: %s (UUID: %s)\n", user.Name, user.ID)) // Add other required fields return result.String() } // formatComment returns a consistently formatted full representation of a comment func formatComment(comment *linear.Comment) string { if comment == nil { return "Comment: Unknown" } userName := "Unknown" if comment.User != nil { userName = comment.User.Name } var result strings.Builder result.WriteString(fmt.Sprintf("Comment by %s (UUID: %s)\n", userName, comment.ID)) result.WriteString(fmt.Sprintf("Body: %s\n", comment.Body)) // Add other required fields return result.String() } ``` #### Entity Identifier Rendering Functions ```go // formatIssueIdentifier returns a consistently formatted identifier for an issue func formatIssueIdentifier(issue *linear.Issue) string { if issue == nil { return "Issue: Unknown" } return fmt.Sprintf("Issue: %s (UUID: %s)", issue.Identifier, issue.ID) } // formatTeamIdentifier returns a consistently formatted identifier for a team func formatTeamIdentifier(team *linear.Team) string { if team == nil { return "Team: Unknown" } return fmt.Sprintf("Team: %s (UUID: %s)", team.Name, team.ID) } // formatUserIdentifier returns a consistently formatted identifier for a user func formatUserIdentifier(user *linear.User) string { if user == nil { return "User: Unknown" } return fmt.Sprintf("User: %s (UUID: %s)", user.Name, user.ID) } // formatCommentIdentifier returns a consistently formatted identifier for a comment func formatCommentIdentifier(comment *linear.Comment) string { if comment == nil { return "Comment: Unknown" } userName := "Unknown" if comment.User != nil { userName = comment.User.Name } return fmt.Sprintf("Comment by %s (UUID: %s)", userName, comment.ID) } ``` ## Detailed Implementation Tasks ### Phase 1: Create Shared Utility Functions 1. Update `pkg/tools/common.go`: - Refactor `resolveParentIssueIdentifier` to `resolveIssueIdentifier` - Add `resolveUserIdentifier` - Ensure all resolution functions follow the same pattern 2. Create `pkg/tools/rendering.go`: - Add formatting functions for each entity type - Ensure consistent formatting across all entity types ### Phase 2: Update Tools For each tool, perform the following tasks: 1. Update the tool description to be concise 2. Update parameter handling to use the appropriate resolution functions 3. Update result formatting to use the rendering functions 4. Ensure retrieval methods include all fields that can be set in create/update methods #### Implementing Rule 4: Field Superset for Retrieval Methods For each entity type, follow these steps: 1. **Identify Modifiable Fields**: - Review all create and update methods to identify fields that can be set or modified - Create a comprehensive list of these fields for each entity type 2. **Categorize Retrieval Methods**: - **Detail Retrieval Methods** (e.g., `linear_get_issue`): Must include all fields - **Overview Retrieval Methods** (e.g., `linear_search_issues`, `linear_get_user_issues`): Only need metadata fields 3. **Update Detail Retrieval Methods**: - Ensure they include all fields that can be set in create/update methods - Modify the formatting functions to include all required fields 4. **Update Overview Retrieval Methods**: - Ensure they include key metadata fields (ID, title, status, priority, etc.) - No need to include full content like descriptions or comments 5. **Entity-Specific Considerations**: - **Issues**: - `linear_get_issue` must include all fields from `linear_create_issue` and `linear_update_issue` - `linear_search_issues` and `linear_get_user_issues` only need metadata fields - **Comments**: - Comments returned in `linear_get_issue` must include all necessary fields from `linear_add_comment` - Overview methods don't need to display comments - **Teams**: - `linear_get_teams` should include all team fields that can be referenced #### Tool-Specific Tasks | Tool | Description Update | Identifier Resolution Update | Rendering Update | |------|-------------------|----------------------------|-----------------| | linear_create_issue | Remove parameter listing and result format explanation | Already uses resolution functions | Use formatIssue for result | | linear_update_issue | Remove parameter listing and result format explanation | Update to use resolveIssueIdentifier | Use formatIssue for result | | linear_search_issues | Remove parameter listing and result format explanation | Update to use resolveTeamIdentifier for teamId | Use formatIssue for each issue in results | | linear_get_user_issues | Remove parameter listing and result format explanation | Add resolveUserIdentifier for userId | Use formatIssue for each issue in results | | linear_get_issue | Remove parameter listing and result format explanation | Update to use resolveIssueIdentifier | Use formatIssue, formatTeam, formatUser, and formatComment | | linear_add_comment | Remove parameter listing and result format explanation | Update to use resolveIssueIdentifier | Use formatIssue and formatComment | | linear_get_teams | Remove parameter listing and result format explanation | No changes needed | Use formatTeam for each team in results | ### Phase 3: Update Tests 1. Update test fixtures to reflect the new formatting 2. Add tests for the new resolution functions 3. Verify that all tests pass with the new implementation ## Detailed Tracking Table | Tool | Task | Status | Notes | |------|------|--------|-------| | Shared | Create resolveIssueIdentifier | Not Started | Extend from resolveParentIssueIdentifier | | Shared | Create resolveUserIdentifier | Not Started | New function | | Shared | Create rendering.go with formatting functions | Not Started | New file | | linear_create_issue | Update description | Not Started | | | linear_create_issue | Update result formatting | Not Started | | | linear_update_issue | Update description | Not Started | | | linear_update_issue | Add issue identifier resolution | Not Started | | | linear_update_issue | Update result formatting | Not Started | | | linear_search_issues | Update description | Not Started | | | linear_search_issues | Add team identifier resolution | Not Started | | | linear_search_issues | Update result formatting | Not Started | | | linear_get_user_issues | Update description | Not Started | | | linear_get_user_issues | Add user identifier resolution | Not Started | | | linear_get_user_issues | Update result formatting | Not Started | | | linear_get_issue | Update description | Not Started | | | linear_get_issue | Add issue identifier resolution | Not Started | | | linear_get_issue | Update result formatting | Not Started | | | linear_get_issue | Ensure all fields from create/update are included | Not Started | Rule 4 implementation | | linear_get_issue | Ensure all comment fields are included | Not Started | Rule 4 implementation | | linear_get_user_issues | Ensure all relevant issue fields are included | Not Started | Rule 4 implementation | | linear_search_issues | Ensure all relevant issue fields are included | Not Started | Rule 4 implementation | | linear_get_teams | Ensure all team fields are included | Not Started | Rule 4 implementation | | linear_add_comment | Update description | Not Started | | | linear_add_comment | Add issue identifier resolution | Not Started | | | linear_add_comment | Update result formatting | Not Started | | | linear_get_teams | Update description | Not Started | | | linear_get_teams | Update result formatting | Not Started | | | Tests | Update test fixtures | Not Started | | | Tests | Add tests for new resolution functions | Not Started | | | Tests | Add tests for field superset compliance | Not Started | Rule 4 testing | ## Example Implementation: linear_create_issue Here's an example of how the `linear_create_issue` tool would be updated: ### Before ```go var CreateIssueTool = mcp.NewTool("linear_create_issue", mcp.WithDescription("Creates a new Linear issue with specified details. Use this to create tickets for tasks, bugs, or feature requests. Returns the created issue's identifier and URL. Supports creating sub-issues and assigning labels."), // ... parameters ... ) // CreateIssueHandler handles the linear_create_issue tool func CreateIssueHandler(linearClient *linear.LinearClient) func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { // ... implementation ... // Return the result resultText := fmt.Sprintf("Created issue: %s\nTitle: %s\nURL: %s", issue.Identifier, issue.Title, issue.URL) return mcp.NewToolResultText(resultText), nil } ``` ### After ```go var CreateIssueTool = mcp.NewTool("linear_create_issue", mcp.WithDescription("Creates a new Linear issue."), // ... parameters ... ) // CreateIssueHandler handles the linear_create_issue tool func CreateIssueHandler(linearClient *linear.LinearClient) func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { // ... implementation ... // Return the result resultText := fmt.Sprintf("%s\nTitle: %s\nURL: %s", formatIssue(issue), issue.Title, issue.URL) return mcp.NewToolResultText(resultText), nil } ``` ## Timeline | Phase | Estimated Duration | Dependencies | |-------|-------------------|--------------| | Phase 1: Create Shared Utility Functions | 1 day | None | | Phase 2: Update Tools | 3 days | Phase 1 | | Phase 3: Update Tests | 1 day | Phase 2 | ## Risks and Mitigations | Risk | Impact | Mitigation | |------|--------|------------| | Breaking changes to API | High | Ensure backward compatibility or version the API | | Test failures | Medium | Update test fixtures and add new tests | | Inconsistent implementation | Medium | Review each tool implementation for consistency | ## Success Criteria 1. All tool descriptions are concise 2. All tools that reference Linear objects accept multiple identifier types 3. All tools render entities in a consistent format 4. Retrieval methods include all fields that can be set in create/update methods 5. All tests pass with the new implementation 6. Code review confirms consistency across all tools ``` -------------------------------------------------------------------------------- /docs/design/002-project-milestone-initiative.md: -------------------------------------------------------------------------------- ```markdown # Design Doc: Project, Milestone, and Initiative Support ## 1. Overview This document outlines the plan to extend the Linear MCP Server with tools to read and manipulate `Project`, `ProjectMilestone`, and `Initiative` entities. This will enhance the server's capabilities, allowing AI assistants to manage a broader range of Linear workflows. ## 2. Guiding Principles * **Consistency**: The new tools and code will follow the existing architecture and design patterns of the server. * **Modularity**: Each entity will be implemented in a modular way, with clear separation between models, client methods, and tool handlers. * **User-Friendliness**: Tools will accept user-friendly identifiers (names, slugs) in addition to UUIDs, similar to the existing `issue` and `team` parameters. * **Testability**: All new functionality will be covered by unit tests using the existing `go-vcr` framework. ## 3. Architecture Changes The existing architecture is well-suited for extension. The primary changes will be: * **`pkg/linear/models.go`**: Add new structs for `Project`, `ProjectMilestone`, `Initiative`, and their related types (e.g., `ProjectConnection`, `ProjectCreateInput`). * **`pkg/linear/client.go`**: Add new methods to the `LinearClient` for interacting with the new entities. * **`pkg/tools/`**: Create new files for each entity's tools (e.g., `project_tools.go`, `milestone_tools.go`, `initiative_tools.go`). * **`pkg/server/server.go`**: Update `RegisterTools` to include the new tools. No fundamental changes to the core server logic or command structure are anticipated. ## 4. Implementation Plan This section details the sub-tasks for implementing each handler. ### 4.1. Project Entity #### 4.1.1. `linear_get_project` Handler - [x] **Model**: Define `Project` and `ProjectConnection` structs in `pkg/linear/models.go`. - [x] **Client**: Implement `GetProject(identifier string)` in `pkg/linear/client.go`. - [x] Add a resolver function to handle UUIDs, names, and slug IDs. - [x] Implement the GraphQL query to fetch a single project. - [x] **Tool**: Create `GetProjectTool` in `pkg/tools/project_tools.go`. - [x] Define the tool with the name `linear_get_project`. - [x] Add a description: "Get a single project by its identifier (ID, name, or slug)." - [x] Define a required `project` string parameter. - [x] **Handler**: Implement `GetProjectHandler` in `pkg/tools/project_tools.go`. - [x] Extract the `project` identifier from the request. - [x] Call `linearClient.GetProject()` with the identifier. - [x] Format the returned `Project` object into a user-friendly string. - [x] Handle errors for not found projects. - [x] **Server**: Register the tool in `pkg/server/server.go`. - [x] **Test**: Add test cases to `pkg/server/tools_test.go`. #### 4.1.2. `linear_search_projects` Handler - [x] **Client**: Implement `SearchProjects(query string)` in `pkg/linear/client.go`. - [x] Implement the GraphQL query for searching projects. - [x] **Tool**: Create `SearchProjectsTool` in `pkg/tools/project_tools.go`. - [x] Define the tool with the name `linear_search_projects`. - [x] Add a description: "Search for projects." - [x] Define an optional `query` string parameter. - [x] **Handler**: Implement `SearchProjectsHandler` in `pkg/tools/project_tools.go`. - [x] Extract the `query` from the request. - [x] Call `linearClient.SearchProjects()`. - [x] Format the list of `Project` objects. - [x] **Server**: Register the tool in `pkg/server/server.go`. - [x] **Test**: Add test cases to `pkg/server/tools_test.go`. #### 4.1.3. `linear_create_project` Handler - [x] **Model**: Define `ProjectCreateInput` struct in `pkg/linear/models.go`. - [x] **Client**: Implement `CreateProject(input ProjectCreateInput)` in `pkg/linear/client.go`. - [x] Implement the GraphQL mutation to create a project. - [x] **Tool**: Create `CreateProjectTool` in `pkg/tools/project_tools.go`. - [x] Define the tool with the name `linear_create_project`. - [x] Add a description: "Create a new project." - [x] Define required parameters: `name`, `teamIds`. - [x] Define optional parameters: `description`, `leadId`, `startDate`, `targetDate`, etc. - [x] **Handler**: Implement `CreateProjectHandler` in `pkg/tools/project_tools.go`. - [x] Build the `ProjectCreateInput` from the request parameters. - [x] Call `linearClient.CreateProject()`. - [x] Format the newly created `Project` object. - [x] **Server**: Register the tool in `pkg/server/server.go` (respecting `writeAccess`). - [x] **Test**: Add test cases to `pkg/server/tools_test.go`. #### 4.1.4. `linear_update_project` Handler - [x] **Model**: Define `ProjectUpdateInput` struct in `pkg/linear/models.go`. - [x] **Client**: Implement `UpdateProject(id string, input ProjectUpdateInput)` in `pkg/linear/client.go`. - [x] Implement the GraphQL mutation to update a project. - [x] **Tool**: Create `UpdateProjectTool` in `pkg/tools/project_tools.go`. - [x] Define the tool with the name `linear_update_project`. - [x] Add a description: "Update an existing project." - [x] Define a required `project` parameter. - [x] Define optional parameters for updatable fields (`name`, `description`, etc.). - [x] **Handler**: Implement `UpdateProjectHandler` in `pkg/tools/project_tools.go`. - [x] Resolve the `project` identifier to a UUID. - [x] Build the `ProjectUpdateInput`. - [x] Call `linearClient.UpdateProject()`. - [x] Format the updated `Project` object. - [x] **Server**: Register the tool in `pkg/server/server.go` (respecting `writeAccess`). - [x] **Test**: Add test cases to `pkg/server/tools_test.go`. ### 4.2. ProjectMilestone Entity #### 4.2.1. `linear_get_milestone` Handler - [x] **Model**: Define `ProjectMilestone` and `ProjectMilestoneConnection` structs in `pkg/linear/models.go`. - [x] **Client**: Implement `GetMilestone(id string)` in `pkg/linear/client.go`. - [x] Implement the GraphQL query to fetch a single milestone. - [x] **Tool**: Create `GetMilestoneTool` in `pkg/tools/milestone_tools.go`. - [x] Define the tool with the name `linear_get_milestone`. - [x] Add a description: "Get a single project milestone by its ID." - [x] Define a required `milestoneId` string parameter. - [x] **Handler**: Implement `GetMilestoneHandler` in `pkg/tools/milestone_tools.go`. - [x] Call `linearClient.GetMilestone()`. - [x] Format the returned `ProjectMilestone` object. - [x] **Server**: Register the tool in `pkg/server/server.go`. - [x] **Test**: Add test cases to `pkg/server/tools_test.go`. #### 4.2.2. `linear_create_milestone` Handler - [x] **Model**: Define `ProjectMilestoneCreateInput` in `pkg/linear/models.go`. - [x] **Client**: Implement `CreateMilestone(input ProjectMilestoneCreateInput)` in `pkg/linear/client.go`. - [x] Implement the GraphQL mutation. - [x] **Tool**: Create `CreateMilestoneTool` in `pkg/tools/milestone_tools.go`. - [x] Define the tool with the name `linear_create_milestone`. - [x] Add a description: "Create a new project milestone." - [x] Define required parameters: `name`, `projectId`. - [x] Define optional parameters: `description`, `targetDate`. - [x] **Handler**: Implement `CreateMilestoneHandler` in `pkg/tools/milestone_tools.go`. - [x] Resolve `projectId`. - [x] Build and call `linearClient.CreateMilestone()`. - [x] Format the new `ProjectMilestone`. - [x] **Server**: Register the tool in `pkg/server/server.go` (respecting `writeAccess`). - [x] **Test**: Add test cases to `pkg/server/tools_test.go`. ### 4.3. Initiative Entity #### 4.3.1. `linear_get_initiative` Handler - [x] **Model**: Define `Initiative` and `InitiativeConnection` structs in `pkg/linear/models.go`. - [x] **Client**: Implement `GetInitiative(identifier string)` in `pkg/linear/client.go`. - [x] Add a resolver for UUID and name. - [x] Implement the GraphQL query. - [x] **Tool**: Create `GetInitiativeTool` in `pkg/tools/initiative_tools.go`. - [x] Define the tool with the name `linear_get_initiative`. - [x] Add a description: "Get a single initiative by its identifier (ID or name)." - [x] Define a required `initiative` string parameter. - [x] **Handler**: Implement `GetInitiativeHandler` in `pkg/tools/initiative_tools.go`. - [x] Call `linearClient.GetInitiative()`. - [x] Format the returned `Initiative` object. - [x] **Server**: Register the tool in `pkg/server/server.go`. - [x] **Test**: Add test cases to `pkg/server/tools_test.go`. #### 4.3.2. `linear_create_initiative` Handler - [x] **Model**: Define `InitiativeCreateInput` in `pkg/linear/models.go`. - [x] **Client**: Implement `CreateInitiative(input InitiativeCreateInput)` in `pkg/linear/client.go`. - [x] Implement the GraphQL mutation. - [x] **Tool**: Create `CreateInitiativeTool` in `pkg/tools/initiative_tools.go`. - [x] Define the tool with the name `linear_create_initiative`. - [x] Add a description: "Create a new initiative." - [x] Define a required `name` parameter. - [x] Define optional parameters like `description`. - [x] **Handler**: Implement `CreateInitiativeHandler` in `pkg/tools/initiative_tools.go`. - [x] Build and call `linearClient.CreateInitiative()`. - [x] Format the new `Initiative`. - [x] **Server**: Register the tool in `pkg/server/server.go` (respecting `writeAccess`). - [x] **Test**: Add test cases to `pkg/server/tools_test.go`. ### 4.4.1 Re-record tests - [x] Coordinate with user to prepare test data and re-record requests - [x] Iterate over each new test cases and validate that it works ### 4.4.2 Issues detected during testing - [x] get_project: filters by slugId ('e1153169a428'), but takes slug ('created-test-project-e1153169a428') as input. Should split string by '-' and used the last element - [x] search_projects: Cannot find multiple projects (prefix search only?) - [x] issues: - [x] display milestone and project association - [x] allow to update milestone and project association - [x] projects: - [x] display initiative association - [x] allow to update initiative association - [x] create_milestone: date parsing issue ## 5. Testing Strategy Recording new test requests and data is the final step of this effort. We rely on the relevant test cases being added beforehand during each step. * **Unit Tests**: Tests are implemented in `pkg/server/tools_test.go`. * **Fixtures**: Use `go-vcr` to record real API interactions for each new client method. Fixtures will be stored in `testdata/fixtures/`. * **Golden Files**: Expected outputs for each test case will be stored in `testdata/golden/`. * **Coverage**: Ensure that both success and error paths are tested for each handler. This includes testing with invalid identifiers, missing parameters, and API errors. ## 6. Future Considerations * **Full CRUD**: This plan covers the most common operations. Full CRUD (including delete) can be added later if needed. * **Relationships**: Add tools for managing relationships between these entities (e.g., adding a project to an initiative). * **Resources**: Expose these new entities as MCP resources for easy reference in other tools. ## 7. Critique and Improvements Based on a review of the initial implementation, several areas for improvement have been identified to enhance tool symmetry and consistency. ### 7.1. Enhance `linear_update_project` - [x] **Symmetry**: The `linear_update_project` tool should support the same set of optional parameters as `linear_create_project`. - [x] **Task**: Add `leadId`, `startDate`, `targetDate`, and `teamIds` as optional parameters to the `UpdateProjectTool` definition in `pkg/tools/project_tools.go`. - [x] **Task**: Update the `UpdateProjectHandler` to handle these new parameters. - [x] **Task**: Update the `linearClient.UpdateProject` method and `ProjectUpdateInput` struct to include these fields. - [ ] **Test**: Add test cases to verify that each field can be updated individually and in combination. ### 7.2. Add `linear_update_milestone` - [x] **Symmetry**: Add a `linear_update_milestone` tool to provide full CRUD operations for milestones. - [x] **Model**: Define `ProjectMilestoneUpdateInput` struct in `pkg/linear/models.go`. - [x] **Client**: Implement `UpdateMilestone(id string, input ProjectMilestoneUpdateInput)` in `pkg/linear/client.go`. - [x] **Tool**: Create `UpdateMilestoneTool` in `pkg/tools/milestone_tools.go` with a required `milestone` parameter and optional `name`, `description`, and `targetDate` parameters. - [x] **Handler**: Implement `UpdateMilestoneHandler` in `pkg/tools/milestone_tools.go`. - [x] **Server**: Register the new tool in `pkg/server/server.go`. - [ ] **Test**: Add test cases for the new tool. ### 7.3. Add `linear_update_initiative` - [x] **Symmetry**: Add a `linear_update_initiative` tool to provide full CRUD operations for initiatives. - [x] **Model**: Define `InitiativeUpdateInput` struct in `pkg/linear/models.go`. - [x] **Client**: Implement `UpdateInitiative(id string, input InitiativeUpdateInput)` in `pkg/linear/client.go`. - [x] **Tool**: Create `UpdateInitiativeTool` in `pkg/tools/initiative_tools.go` with a required `initiative` parameter and optional `name` and `description` parameters. - [x] **Handler**: Implement `UpdateInitiativeHandler` in `pkg/tools/initiative_tools.go`. - [x] **Server**: Register the new tool in `pkg/server/server.go`. - [ ] **Test**: Add test cases for the new tool. ### 7.4. Standardize Identifier Parameters - [x] **Consistency**: Ensure all `get` and `update` tools use a consistent and user-friendly identifier parameter. - [x] **Task**: In `pkg/tools/milestone_tools.go`, rename the `milestoneId` parameter of `GetMilestoneTool` to `milestone`. - [x] **Task**: Update the `GetMilestoneHandler` to use the `milestone` parameter. - [x] **Task**: Enhance `linearClient.GetMilestone` to resolve the milestone by name in addition to ID, similar to how `GetProject` works. - [ ] **Test**: Update existing tests for `linear_get_milestone` and add new tests for name-based resolution. ``` -------------------------------------------------------------------------------- /memory-bank/activeContext.md: -------------------------------------------------------------------------------- ```markdown # Active Context: Linear MCP Server ## Current Work Focus The current focus is on enhancing the functionality and user experience of the Linear MCP Server. This includes: 1. Improving the user experience by adding a setup command that simplifies the installation and configuration process 2. Enhancing the Linear API integration with support for more advanced features 3. Supporting multiple AI assistants (starting with Cline) 4. Ensuring cross-platform compatibility 5. Expanding the capabilities of existing MCP tools ## Recent Changes 1. Completed Tool Standardization Testing: - Updated test fixtures to reflect the new standardized format - Updated test cases to use the new parameter names (e.g., `issue` instead of `issueId`) - Verified that all tests pass with the new implementation - Updated tracking document to mark Phase 3 (Update Tests) as completed - Updated progress.md to reflect the completion of Tool Standardization testing 2. Implemented Tool Standardization: - Created shared utility functions for entity rendering and identifier resolution - Updated all tools to follow standardization rules: - Concise descriptions that focus only on functionality - Flexible identifier resolution for all entity references - Consistent entity rendering with both full and identifier formats - Consistent parameter naming that reflects the entity type (e.g., `issue` instead of `issueId`) - Created comprehensive documentation in a series of PRD files (000, 002, 003, 004, 005) - Updated tracking document to reflect implementation progress 2. Implemented CLI framework with subcommands: - Added the Cobra library for command-line handling - Restructured the main.go file to support subcommands - Created a root command that serves as the base for all subcommands - Moved the existing server functionality to a server subcommand 2. Created a setup command: - Implemented a setup command that automates the installation and configuration process - Added support for the Cline AI assistant - Implemented binary discovery and download functionality - Added configuration file management for AI assistants 3. Updated documentation: - Updated README.md with information about the new setup command - Added examples of how to use the setup command - Clarified the usage of the server command 4. Enhanced `linear_create_issue` tool: - Added support for creating sub-issues by specifying a parent issue ID or identifier (e.g., "TEAM-123") - Added support for assigning labels during issue creation using label IDs or names - Implemented resolution functions for parent issue identifiers and label names - Updated the Linear client to handle these new parameters - Added test cases and fixtures for the new functionality - Updated documentation to reflect the new capabilities 5. Fixed JSON unmarshaling issue with Labels field: - Updated the `Issue` struct in `models.go` to change the `Labels` field from `[]Label` to `*LabelConnection` - Added a new `LabelConnection` struct to match the structure returned by the Linear API - Updated test fixtures and golden files to reflect the changes - Added a new test case for creating issues with team key 6. Fixed label resolution issue: - Updated the GraphQL query in `GetLabelsByName` function to change the `$teamId` parameter type from `ID!` to `String!` - Re-recorded test fixtures for label-related tests - Updated golden files to reflect the new error messages - All tests now pass successfully 7. Fixed parent issue identifier resolution: - Updated the `GetIssueByIdentifier` function to split the identifier (e.g., "TEAM-123") into team key and number parts - Modified the GraphQL query to use the team key and number in the filter instead of the full identifier - Added proper error handling for invalid identifier formats - Added a new test case for creating sub-issues using human-readable identifiers - All tests now pass successfully 8. Enhanced Claude Code Support in Setup Command: - **Feature 1**: Register to all existing projects when no --project-path is specified - Modified `setupClaudeCode` function to read existing projects from `.claude.json` - Added `getAllExistingProjects` helper function to extract project paths - Implemented logic to register Linear MCP server to all existing projects automatically - Added proper error handling when no existing projects are found - **Feature 2**: Support multiple project paths separated by commas - Added comma-separated project path parsing with whitespace trimming - Modified registration logic to handle multiple target projects - Added validation for empty project path lists - **Implementation Details**: - Created `registerLinearToProject` helper function for reusable project registration logic - Preserved all existing configuration settings and project structures - Added comprehensive logging to show which projects are being registered - Updated flag help text to document the new behavior - **Testing**: - Added 5 new comprehensive test cases covering all scenarios: - Register to all existing projects (empty project path) - Multiple comma-separated project paths with whitespace handling - Mixed existing and new projects - Error handling for empty project lists - Error handling when no existing projects found - All tests pass successfully - Manual testing confirmed functionality works as expected 9. **UPDATED**: Implemented User-Scoped MCP Server Registration for Claude Code: - **Problem Identified**: The previous implementation tried to register to all existing projects when no project path was specified, which was complex and didn't match Claude Code's intended behavior - **Solution Implemented**: Use user-scoped `mcpServers` registration instead of project-scoped when no project path is specified - **Key Changes**: - **Updated `setupClaudeCode` function**: Now registers to user-scoped `mcpServers` (root level) when `projectPath` is empty - **Added `registerLinearToUserScope` function**: Handles registration to the root-level `mcpServers` object that applies to all projects - **Removed obsolete `getAllExistingProjects` function**: No longer needed since we use user-scoped registration - **Updated flag help text**: Changed from "register to all existing projects" to "register to user scope for all projects" - **Benefits of User-Scoped Approach**: - **Simpler logic**: No need to iterate through existing projects - **Better user experience**: Works even when no projects exist yet - **Future-proof**: Automatically applies to new projects created later - **Matches Claude Code design**: Uses the intended global registration mechanism - **JSON Structure Changes**: - **When `projectPath` is empty**: Registers to root-level `mcpServers` (user-scoped) - **When `projectPath` is specified**: Continues using project-scoped registration in `projects[path].mcpServers` - **Updated Test Cases**: - **Modified existing test**: "Claude Code Register to All Existing Projects" → "Claude Code Register to User Scope with Existing Projects" - **Updated error test**: "Claude Code No Existing Projects and No Project Path" now expects success with user-scoped registration - **Added new test cases**: - User-scoped registration with existing user-scoped servers - User-scoped update of existing Linear server - Comprehensive coverage of both user-scoped and project-scoped scenarios - **Implementation Status**: Complete and ready for testing ## Next Steps 1. **Testing the Setup Command**: - Test the setup command on different platforms (Linux, macOS, Windows) - Verify that the configuration files are correctly created - Ensure that the binary download works correctly 2. **Adding Support for More AI Assistants**: - Research other AI assistants that could benefit from Linear integration - Implement support for these assistants in the setup command - Update documentation with information about the new assistants 3. **Future Enhancements**: - Add more Linear API features as MCP tools - Improve error handling and reporting - Add configuration file support for the server ## Active Decisions and Considerations ### Tool Standardization Approach - **Decision**: Implement standardization in phases, focusing on shared utility functions first - **Rationale**: Creating shared functions first ensures consistency across all tools - **Alternatives Considered**: Updating each tool individually, creating a new set of tools - **Implications**: More maintainable codebase with consistent patterns ### Tool Description Style - **Decision**: Make tool descriptions concise and focused on functionality - **Rationale**: Concise descriptions are easier to read and understand - **Alternatives Considered**: Keeping verbose descriptions, creating separate documentation - **Implications**: Improved user experience with clearer tool descriptions ### Parameter Naming Convention - **Decision**: Use entity names for parameters that accept identifiers (e.g., `issue` instead of `issueId`) - **Rationale**: Parameter names should reflect what they represent rather than implementation details - **Alternatives Considered**: Keeping technical names like `issueId`, using different naming patterns - **Implications**: More intuitive API that aligns with the flexible identifier resolution approach ### Identifier Resolution Strategy - **Decision**: Extend existing resolution functions and create new ones as needed - **Rationale**: Builds on existing functionality while ensuring consistency - **Alternatives Considered**: Creating entirely new resolution system, handling resolution in each tool - **Implications**: More flexible parameter handling with consistent behavior ### Entity Rendering Approach - **Decision**: Create two types of formatting functions for each entity type - **Rationale**: Distinguishes between full entity rendering and entity identifier rendering - **Alternatives Considered**: Single formatting function, custom formatting in each tool, using templates - **Implications**: Consistent user experience with standardized output format that is appropriate for the context ### Full Entity vs. Identifier Rendering - **Decision**: Use full entity rendering for primary entities and identifier rendering for referenced entities - **Rationale**: Provides comprehensive information for primary entities while keeping references concise - **Alternatives Considered**: Using only full rendering or only identifier rendering for all cases - **Implications**: Better readability and more intuitive output format ### CLI Framework Selection - **Decision**: Use the Cobra library for command-line handling - **Rationale**: Cobra is a widely used library for Go CLI applications with good documentation and community support - **Alternatives Considered**: urfave/cli, flag package - **Implications**: Provides a consistent way to handle subcommands and flags ### Setup Command Design - **Decision**: Implement a setup command that automates the installation and configuration process - **Rationale**: Simplifies the user experience by automating manual steps - **Alternatives Considered**: Keeping the bash script, creating a separate tool - **Implications**: Users can easily set up the server for use with AI assistants ### AI Assistant Support - **Decision**: Start with Cline support and design for extensibility - **Rationale**: Cline is the primary target, but the design should allow for adding more assistants - **Alternatives Considered**: Supporting only Cline, supporting multiple assistants from the start - **Implications**: The code is structured to easily add support for more assistants in the future ### Binary Management - **Decision**: Check for existing binary before downloading - **Rationale**: Avoids unnecessary downloads if the binary is already installed - **Alternatives Considered**: Always downloading the latest version - **Implications**: Faster setup process for users who already have the binary ### Configuration File Management - **Decision**: Merge new settings with existing settings - **Rationale**: Preserves user's existing configuration while adding the Linear MCP server - **Alternatives Considered**: Overwriting the entire file - **Implications**: Users can have multiple MCP servers configured ### Linear Issue Creation Enhancement - **Decision**: Enhance the `linear_create_issue` tool with support for user-friendly identifiers - **Rationale**: Provides more flexibility and better user experience when creating issues - **Alternatives Considered**: Requiring UUIDs only, creating separate tools for different identifier types - **Implications**: Users can create issues with more intuitive identifiers without needing to look up UUIDs ### Identifier Resolution Implementation - **Decision**: Implement separate resolution functions for parent issues and labels - **Rationale**: Keeps the code modular and easier to maintain - **Alternatives Considered**: Implementing a generic resolution function, handling resolution in the handler directly - **Implications**: Code is more maintainable and easier to extend for future enhancements ### JSON Structure Handling - **Decision**: Update the `Issue` struct to match the nested structure returned by the Linear API - **Rationale**: Ensures proper JSON unmarshaling of API responses - **Alternatives Considered**: Custom unmarshaling logic, flattening the structure in the client - **Implications**: More robust handling of API responses and fewer unmarshaling errors ### GraphQL Parameter Type Correction - **Decision**: Update the GraphQL query parameter types to match the Linear API expectations - **Rationale**: Ensures proper validation of GraphQL queries by the Linear API - **Alternatives Considered**: Custom error handling for API validation errors - **Implications**: More reliable API requests and fewer validation errors ### Parent Issue Identifier Resolution - **Decision**: Split the identifier into team key and number parts for the GraphQL query - **Rationale**: The Linear API doesn't support searching for issues by the full identifier directly - **Alternatives Considered**: Using a different API endpoint, implementing a custom search function - **Implications**: More reliable resolution of human-readable identifiers to UUIDs ## Open Questions 1. Should we add support for more AI assistants in the setup command? 2. Do we need to add any additional validation steps for the API key? 3. Should we implement automatic updates for the binary? 4. How can we improve the error handling for network and file system operations? ``` -------------------------------------------------------------------------------- /cmd/setup.go: -------------------------------------------------------------------------------- ```go package cmd import ( "encoding/json" "fmt" "io" "os" "os/exec" "path/filepath" "runtime" "strings" "github.com/geropl/linear-mcp-go/pkg/server" "github.com/spf13/cobra" ) // setupCmd represents the setup command var setupCmd = &cobra.Command{ Use: "setup", Short: "Set up the Linear MCP server for use with an AI assistant", Long: `Set up the Linear MCP server for use with an AI assistant. This command installs the Linear MCP server and configures it for use with the specified AI assistant tool(s). Currently supported tools: cline, roo-code, claude-code, ona`, Run: func(cmd *cobra.Command, args []string) { toolParam, _ := cmd.Flags().GetString("tool") writeAccess, _ := cmd.Flags().GetBool("write-access") writeAccessChanged := cmd.Flags().Changed("write-access") autoApprove, _ := cmd.Flags().GetString("auto-approve") projectPath, _ := cmd.Flags().GetString("project-path") // Check if the Linear API key is provided in the environment (for tools that need it) apiKey := os.Getenv("LINEAR_API_KEY") tools := strings.Split(toolParam, ",") for _, t := range tools { doesNotNeedApiKey := strings.TrimSpace(t) == "ona" if doesNotNeedApiKey { continue } if apiKey == "" { fmt.Println("Error: LINEAR_API_KEY environment variable is required") fmt.Println("Please set it before running the setup command:") fmt.Println("export LINEAR_API_KEY=your_linear_api_key") os.Exit(1) } } // Create the MCP servers directory if it doesn't exist homeDir, err := os.UserHomeDir() if err != nil { fmt.Printf("Error getting user home directory: %v\n", err) os.Exit(1) } mcpServersDir := filepath.Join(homeDir, "mcp-servers") if err := os.MkdirAll(mcpServersDir, 0755); err != nil { fmt.Printf("Error creating MCP servers directory: %v\n", err) os.Exit(1) } // Check if the Linear MCP binary is already on the path binaryPath, found := checkBinary() if !found { fmt.Printf("Linear MCP binary not found on path, copying current binary to '%s'...\n", binaryPath) err := copySelfToBinaryPath(binaryPath) if err != nil { fmt.Printf("Error copying Linear MCP binary: %v\n", err) os.Exit(1) } } // Process each tool hasErrors := false for _, t := range tools { t = strings.TrimSpace(t) if t == "" { continue } fmt.Printf("Setting up tool: %s\n", t) // Set up the tool-specific configuration var err error switch strings.ToLower(t) { case "cline": err = setupCline(binaryPath, apiKey, writeAccess, autoApprove) case "roo-code": err = setupRooCode(binaryPath, apiKey, writeAccess, autoApprove) case "claude-code": err = setupClaudeCode(binaryPath, apiKey, writeAccess, autoApprove, projectPath) case "ona": err = setupOna(binaryPath, apiKey, writeAccess, writeAccessChanged, autoApprove, projectPath) default: fmt.Printf("Unsupported tool: %s\n", t) fmt.Println("Currently supported tools: cline, roo-code, claude-code, ona") hasErrors = true continue } if err != nil { fmt.Printf("Error setting up %s: %v\n", t, err) hasErrors = true } else { fmt.Printf("Linear MCP server successfully set up for %s\n", t) } } if hasErrors { os.Exit(1) } }, } func init() { rootCmd.AddCommand(setupCmd) // Add flags to the setup command setupCmd.Flags().String("tool", "cline", "The AI assistant tool(s) to set up for (comma-separated, e.g., cline,roo-code,claude-code,ona)") setupCmd.Flags().Bool("write-access", false, "Enable write operations (default: false)") setupCmd.Flags().String("auto-approve", "", "Comma-separated list of tool names to auto-approve, or 'allow-read-only' to auto-approve all read-only tools") setupCmd.Flags().String("project-path", "", "The project path(s) for claude-code project-scoped configuration (comma-separated for multiple projects, or empty to register to user scope for all projects)") } // checkBinary checks if the Linear MCP binary is already on the path func checkBinary() (string, bool) { // Try to find the binary on the path path, err := exec.LookPath("linear-mcp-go") if err == nil { fmt.Printf("Found Linear MCP binary at %s\n", path) return path, true } // Check if the binary exists in the home directory homeDir, err := os.UserHomeDir() if err != nil { return "", false } binaryPath := filepath.Join(homeDir, "mcp-servers", "linear-mcp-go") if runtime.GOOS == "windows" { binaryPath += ".exe" } if _, err := os.Stat(binaryPath); err == nil { fmt.Printf("Found Linear MCP binary at %s\n", binaryPath) return binaryPath, true } return binaryPath, false } // copySelfToBinaryPath copies the current executable to the specified path func copySelfToBinaryPath(binaryPath string) error { // Get the path to the current executable execPath, err := os.Executable() if err != nil { return fmt.Errorf("failed to get executable path: %w", err) } // Check if the destination is the same as the source absExecPath, _ := filepath.Abs(execPath) absDestPath, _ := filepath.Abs(binaryPath) if absExecPath == absDestPath { return nil // Already in the right place } // Copy the file sourceFile, err := os.Open(execPath) if err != nil { return fmt.Errorf("failed to open source file: %w", err) } defer sourceFile.Close() err = os.MkdirAll(filepath.Dir(binaryPath), 0755) if err != nil { return fmt.Errorf("failed to create destination directory: %w", err) } destFile, err := os.Create(binaryPath) if err != nil { return fmt.Errorf("failed to create destination file: %w", err) } defer destFile.Close() if _, err := io.Copy(destFile, sourceFile); err != nil { return fmt.Errorf("failed to copy file: %w", err) } // Make the binary executable if runtime.GOOS != "windows" { if err := os.Chmod(binaryPath, 0755); err != nil { return fmt.Errorf("failed to make binary executable: %w", err) } } fmt.Printf("Linear MCP server installed successfully at %s\n", binaryPath) return nil } // getOnaConfigPath determines the configuration file path for Ona func getOnaConfigPath(projectPath string) (string, error) { cwd, err := os.Getwd() if err != nil { return "", fmt.Errorf("failed to get current working directory: %w", err) } // Default to current working directory baseDir := cwd // If project path is specified, use the first one if projectPath != "" { paths := strings.Split(projectPath, ",") trimmedPath := strings.TrimSpace(paths[0]) if trimmedPath != "" { baseDir = trimmedPath // If absolute path doesn't exist, treat as relative to current directory if filepath.IsAbs(trimmedPath) { if _, err := os.Stat(trimmedPath); os.IsNotExist(err) { relativePath := strings.TrimPrefix(trimmedPath, "/") baseDir = filepath.Join(cwd, relativePath) } } } } return filepath.Join(baseDir, ".ona", "mcp-config.json"), nil } // extractTrailingWhitespace extracts trailing whitespace (newlines, spaces, tabs) from a string func extractTrailingWhitespace(content string) string { // Find the last non-whitespace character i := len(content) - 1 for i >= 0 && (content[i] == ' ' || content[i] == '\t' || content[i] == '\n' || content[i] == '\r') { i-- } // Return everything after the last non-whitespace character if i < len(content)-1 { return content[i+1:] } return "" } // setupOna sets up the Linear MCP server for Ona func setupOna(binaryPath, apiKey string, writeAccess bool, writeAccessChanged bool, autoApprove, projectPath string) error { configPath, err := getOnaConfigPath(projectPath) if err != nil { return err } // Create the .ona directory if it doesn't exist if err := os.MkdirAll(filepath.Dir(configPath), 0755); err != nil { return fmt.Errorf("failed to create .ona directory: %w", err) } // Prepare server arguments serverArgs := []string{"serve"} // Only add write-access argument if it was explicitly set if writeAccessChanged { serverArgs = append(serverArgs, fmt.Sprintf("--write-access=%t", writeAccess)) } // Create the linear server configuration linearServerConfig := map[string]interface{}{ "command": binaryPath, "args": serverArgs, "disabled": false, } // Ona will automatically provide LINEAR_API_KEY from environment // No need to explicitly set it in the configuration // Read existing configuration or create new one var config map[string]interface{} var originalTrailingWhitespace string data, err := os.ReadFile(configPath) if err != nil { if !os.IsNotExist(err) { return fmt.Errorf("failed to read existing ona config: %w", err) } // Initialize with empty structure if file doesn't exist config = map[string]interface{}{ "mcpServers": map[string]interface{}{}, } } else { // Preserve trailing whitespace from original file originalContent := string(data) originalTrailingWhitespace = extractTrailingWhitespace(originalContent) if err := json.Unmarshal(data, &config); err != nil { return fmt.Errorf("failed to parse existing ona config: %w", err) } // Ensure mcpServers field exists if config["mcpServers"] == nil { config["mcpServers"] = map[string]interface{}{} } } // Get or create mcpServers map mcpServers, ok := config["mcpServers"].(map[string]interface{}) if !ok { mcpServers = map[string]interface{}{} config["mcpServers"] = mcpServers } // Add/update the linear server configuration mcpServers["linear"] = linearServerConfig // Write the updated configuration updatedData, err := json.MarshalIndent(config, "", " ") if err != nil { return fmt.Errorf("failed to marshal ona config: %w", err) } // Append the original trailing whitespace to preserve formatting finalData := append(updatedData, []byte(originalTrailingWhitespace)...) if err := os.WriteFile(configPath, finalData, 0644); err != nil { return fmt.Errorf("failed to write ona config: %w", err) } fmt.Printf("Ona MCP configuration updated at %s\n", configPath) return nil } // setupTool sets up the Linear MCP server for a specific tool func setupTool(toolName string, binaryPath, apiKey string, writeAccess bool, autoApprove string, configDir string) error { // Create the config directory if it doesn't exist if err := os.MkdirAll(configDir, 0755); err != nil { return fmt.Errorf("failed to create config directory: %w", err) } serverArgs := []string{"serve"} if writeAccess { serverArgs = append(serverArgs, "--write-access=true") } // Process auto-approve flag autoApproveTools := []string{} if autoApprove != "" { if autoApprove == "allow-read-only" { // Get the list of read-only tools for k := range server.GetReadOnlyToolNames() { autoApproveTools = append(autoApproveTools, k) } } else { // Split comma-separated list for _, tool := range strings.Split(autoApprove, ",") { trimmedTool := strings.TrimSpace(tool) if trimmedTool != "" { autoApproveTools = append(autoApproveTools, trimmedTool) } } } } // Create the MCP settings file settingsPath := filepath.Join(configDir, "cline_mcp_settings.json") newSettings := map[string]interface{}{ "mcpServers": map[string]interface{}{ "linear": map[string]interface{}{ "command": binaryPath, "args": serverArgs, "env": map[string]string{"LINEAR_API_KEY": apiKey}, "disabled": false, "autoApprove": autoApproveTools, }, }, } // Check if the settings file already exists var settings map[string]interface{} if _, err := os.Stat(settingsPath); err == nil { // Read the existing settings data, err := os.ReadFile(settingsPath) if err != nil { return fmt.Errorf("failed to read existing settings: %w", err) } // Parse the existing settings if err := json.Unmarshal(data, &settings); err != nil { return fmt.Errorf("failed to parse existing settings: %w", err) } // Merge the new settings with the existing settings if mcpServers, ok := settings["mcpServers"].(map[string]interface{}); ok { mcpServers["linear"] = newSettings["mcpServers"].(map[string]interface{})["linear"] } else { settings["mcpServers"] = newSettings["mcpServers"] } } else { // Use the new settings settings = newSettings } // Write the settings to the file data, err := json.MarshalIndent(settings, "", " ") if err != nil { return fmt.Errorf("failed to marshal settings: %w", err) } if err := os.WriteFile(settingsPath, data, 0644); err != nil { return fmt.Errorf("failed to write settings: %w", err) } fmt.Printf("%s MCP settings updated at %s\n", toolName, settingsPath) return nil } // setupCline sets up the Linear MCP server for Cline func setupCline(binaryPath, apiKey string, writeAccess bool, autoApprove string) error { // Determine the Cline config directory homeDir, err := os.UserHomeDir() if err != nil { return fmt.Errorf("failed to get user home directory: %w", err) } var configDir string switch runtime.GOOS { case "darwin": configDir = filepath.Join(homeDir, "Library", "Application Support", "Code", "User", "globalStorage", "saoudrizwan.claude-dev", "settings") case "linux": configDir = filepath.Join(homeDir, ".vscode-server", "data", "User", "globalStorage", "saoudrizwan.claude-dev", "settings") case "windows": configDir = filepath.Join(homeDir, "AppData", "Roaming", "Code", "User", "globalStorage", "saoudrizwan.claude-dev", "settings") default: return fmt.Errorf("unsupported OS: %s", runtime.GOOS) } return setupTool("Cline", binaryPath, apiKey, writeAccess, autoApprove, configDir) } // setupRooCode sets up the Linear MCP server for Roo Code func setupRooCode(binaryPath, apiKey string, writeAccess bool, autoApprove string) error { // Determine the Roo Code config directory homeDir, err := os.UserHomeDir() if err != nil { return fmt.Errorf("failed to get user home directory: %w", err) } var configDir string switch runtime.GOOS { case "darwin": configDir = filepath.Join(homeDir, "Library", "Application Support", "Code", "User", "globalStorage", "rooveterinaryinc.roo-cline", "settings") case "linux": configDir = filepath.Join(homeDir, ".vscode-server", "data", "User", "globalStorage", "rooveterinaryinc.roo-cline", "settings") case "windows": configDir = filepath.Join(homeDir, "AppData", "Roaming", "Code", "User", "globalStorage", "rooveterinaryinc.roo-cline", "settings") default: return fmt.Errorf("unsupported OS: %s", runtime.GOOS) } return setupTool("Roo Code", binaryPath, apiKey, writeAccess, autoApprove, configDir) } // setupClaudeCode sets up the Linear MCP server for Claude Code func setupClaudeCode(binaryPath, apiKey string, writeAccess bool, autoApprove, projectPath string) error { if runtime.GOOS != "linux" { return fmt.Errorf("claude-code is only supported on Linux") } homeDir, err := os.UserHomeDir() if err != nil { return fmt.Errorf("failed to get user home directory: %w", err) } configPath := filepath.Join(homeDir, ".claude.json") if err := os.MkdirAll(filepath.Dir(configPath), 0755); err != nil { return fmt.Errorf("failed to create directory '%s': %w", filepath.Dir(configPath), err) } // Use flexible map structure to preserve all existing settings var settings map[string]interface{} data, err := os.ReadFile(configPath) if err != nil { if !os.IsNotExist(err) { return fmt.Errorf("failed to read claude code settings: %w", err) } // Initialize with empty structure if file doesn't exist settings = map[string]interface{}{ "projects": map[string]interface{}{}, } } else { if err := json.Unmarshal(data, &settings); err != nil { return fmt.Errorf("failed to parse claude code settings: %w", err) } // Ensure projects field exists if settings["projects"] == nil { settings["projects"] = map[string]interface{}{} } } serverArgs := []string{"serve"} if writeAccess { serverArgs = append(serverArgs, "--write-access=true") } autoApproveTools := []string{} if autoApprove != "" { if autoApprove == "allow-read-only" { for k := range server.GetReadOnlyToolNames() { autoApproveTools = append(autoApproveTools, k) } } else { for _, tool := range strings.Split(autoApprove, ",") { trimmedTool := strings.TrimSpace(tool) if trimmedTool != "" { autoApproveTools = append(autoApproveTools, trimmedTool) } } } } linearServerConfig := map[string]interface{}{ "type": "stdio", "command": binaryPath, "args": serverArgs, "env": map[string]string{"LINEAR_API_KEY": apiKey}, "disabled": false, "autoApprove": autoApproveTools, } if projectPath == "" { // Register to user-scoped mcpServers (applies to all projects) if err := registerLinearToUserScope(settings, linearServerConfig); err != nil { return fmt.Errorf("failed to register Linear MCP server to user scope: %w", err) } fmt.Printf("Registered Linear MCP server to user scope (applies to all projects)\n") } else { // Parse comma-separated project paths and register to specific projects var targetProjects []string for _, path := range strings.Split(projectPath, ",") { trimmedPath := strings.TrimSpace(path) if trimmedPath != "" { targetProjects = append(targetProjects, trimmedPath) } } if len(targetProjects) == 0 { return fmt.Errorf("no valid project paths provided") } fmt.Printf("Registering Linear MCP server to %d specified projects\n", len(targetProjects)) for _, projPath := range targetProjects { if err := registerLinearToProject(settings, projPath, linearServerConfig); err != nil { return fmt.Errorf("failed to register Linear MCP server to project '%s': %w", projPath, err) } fmt.Printf(" - Registered to project: %s\n", projPath) } } updatedData, err := json.MarshalIndent(settings, "", " ") if err != nil { return fmt.Errorf("failed to marshal claude code settings: %w", err) } if err := os.WriteFile(configPath, updatedData, 0644); err != nil { return fmt.Errorf("failed to write claude code settings: %w", err) } fmt.Printf("Claude Code MCP settings updated at %s\n", configPath) return nil } // registerLinearToUserScope registers the Linear MCP server to user-scoped mcpServers (applies to all projects) func registerLinearToUserScope(settings map[string]interface{}, linearServerConfig map[string]interface{}) error { // Get or create user-scoped mcpServers var mcpServers map[string]interface{} if existingMcpServers, exists := settings["mcpServers"]; exists { if mcpServersMap, ok := existingMcpServers.(map[string]interface{}); ok { mcpServers = mcpServersMap } else { // If existing mcpServers is not a map, create a new one mcpServers = map[string]interface{}{} } } else { mcpServers = map[string]interface{}{} } // Add/update the linear server configuration mcpServers["linear"] = linearServerConfig settings["mcpServers"] = mcpServers return nil } // registerLinearToProject registers the Linear MCP server to a specific project func registerLinearToProject(settings map[string]interface{}, projectPath string, linearServerConfig map[string]interface{}) error { // Get projects map projects, ok := settings["projects"].(map[string]interface{}) if !ok { projects = map[string]interface{}{} settings["projects"] = projects } // Get or create the specific project var project map[string]interface{} if existingProject, exists := projects[projectPath]; exists { if projectMap, ok := existingProject.(map[string]interface{}); ok { project = projectMap } else { // If existing project is not a map, create a new one project = map[string]interface{}{} } } else { project = map[string]interface{}{} } // Get or create mcpServers for this project var mcpServers map[string]interface{} if existingMcpServers, exists := project["mcpServers"]; exists { if mcpServersMap, ok := existingMcpServers.(map[string]interface{}); ok { mcpServers = mcpServersMap } else { // If existing mcpServers is not a map, create a new one mcpServers = map[string]interface{}{} } } else { mcpServers = map[string]interface{}{} } // Add/update the linear server configuration mcpServers["linear"] = linearServerConfig project["mcpServers"] = mcpServers projects[projectPath] = project return nil } ``` -------------------------------------------------------------------------------- /pkg/server/tools_test.go: -------------------------------------------------------------------------------- ```go package server import ( "context" "path/filepath" "testing" "github.com/geropl/linear-mcp-go/pkg/linear" "github.com/geropl/linear-mcp-go/pkg/tools" "github.com/google/go-cmp/cmp" "github.com/mark3labs/mcp-go/mcp" ) // Shared constants and expectation struct are defined in test_helpers.go func TestHandlers(t *testing.T) { // Define test cases tests := []struct { handler string name string args map[string]interface{} write bool }{ // GetTeamsHandler test cases { handler: "get_teams", name: "Get Teams", args: map[string]interface{}{ "name": TEAM_NAME, }, }, // CreateIssueHandler test cases { handler: "create_issue", name: "Valid issue with team", args: map[string]interface{}{ "title": "Test Issue", "team": TEAM_ID, }, write: true, }, { handler: "create_issue", name: "Valid issue with team UUID", args: map[string]interface{}{ "title": "Test Issue with team UUID", "team": TEAM_ID, }, write: true, }, { handler: "create_issue", name: "Valid issue with team name", args: map[string]interface{}{ "title": "Test Issue with team name", "team": TEAM_NAME, }, write: true, }, { handler: "create_issue", name: "Valid issue with team key", args: map[string]interface{}{ "title": "Test Issue with team key", "team": TEAM_KEY, }, write: true, }, { handler: "create_issue", name: "Create sub issue", args: map[string]interface{}{ "title": "Sub Issue", "team": TEAM_ID, "makeSubissueOf": "1c2de93f-4321-4015-bfde-ee893ef7976f", // UUID for TEST-10 }, write: true, }, { handler: "create_issue", name: "Create sub issue from identifier", args: map[string]interface{}{ "title": "Sub Issue", "team": TEAM_ID, "makeSubissueOf": "TEST-10", }, write: true, }, { handler: "create_issue", name: "Create issue with labels", args: map[string]interface{}{ "title": "Issue with Labels", "team": TEAM_ID, "labels": "team label 1", }, write: true, }, { handler: "create_issue", name: "Create sub issue with labels", args: map[string]interface{}{ "title": "Sub Issue with Labels", "team": TEAM_ID, "makeSubissueOf": "1c2de93f-4321-4015-bfde-ee893ef7976f", // UUID for TEST-10 "labels": "ws-label 2,Feature", }, write: true, }, { handler: "create_issue", name: "Create issue with project ID", args: map[string]interface{}{ "title": "Issue with Project ID", "team": TEAM_ID, "project": PROJECT_ID, }, write: true, }, { handler: "create_issue", name: "Create issue with project name", args: map[string]interface{}{ "title": "Issue with Project Name", "team": TEAM_ID, "project": "MCP tool investigation", }, write: true, }, { handler: "create_issue", name: "Create issue with project slug", args: map[string]interface{}{ "title": "Issue with Project Slug", "team": TEAM_ID, "project": "mcp-tool-investigation-ae44897e42a7", }, write: true, }, { handler: "create_issue", name: "Create issue with invalid project", args: map[string]interface{}{ "title": "Issue with Invalid Project", "team": TEAM_ID, "project": "non-existent-project", }, write: true, }, { handler: "create_issue", name: "Missing title", args: map[string]interface{}{ "team": TEAM_ID, }, }, { handler: "create_issue", name: "Missing team", args: map[string]interface{}{ "title": "Test Issue", }, }, { handler: "create_issue", name: "Invalid team", args: map[string]interface{}{ "title": "Test Issue", "team": "NonExistentTeam", }, }, // UpdateIssueHandler test cases { handler: "update_issue", name: "Valid update", args: map[string]interface{}{ "issue": ISSUE_ID, "title": "Updated Test Issue", }, write: true, }, { handler: "update_issue", name: "Missing id", args: map[string]interface{}{ "title": "Updated Test Issue", }, }, // SearchIssuesHandler test cases { handler: "search_issues", name: "Search by team", args: map[string]interface{}{ "team": TEAM_ID, "limit": float64(5), }, }, { handler: "search_issues", name: "Search by query", args: map[string]interface{}{ "query": "test", "limit": float64(5), }, }, // GetUserIssuesHandler test cases { handler: "get_user_issues", name: "Current user issues", args: map[string]interface{}{ "limit": float64(5), }, }, { handler: "get_user_issues", name: "Specific user issues", args: map[string]interface{}{ "user": USER_ID, "limit": float64(5), }, }, // GetIssueHandler test cases { handler: "get_issue", name: "Valid issue", args: map[string]interface{}{ "issue": ISSUE_ID, }, }, { handler: "get_issue", name: "Get comment issue", args: map[string]interface{}{ "issue": COMMENT_ISSUE_ID, }, }, { handler: "get_issue", name: "Missing issue", args: map[string]interface{}{}, }, { handler: "get_issue", name: "Missing issueId", args: map[string]interface{}{ "issue": "NONEXISTENT-123", }, }, // GetIssueCommentsHandler test cases { handler: "get_issue_comments", name: "Valid issue", args: map[string]interface{}{ "issue": ISSUE_ID, }, }, { handler: "get_issue_comments", name: "Missing issue", args: map[string]interface{}{}, }, { handler: "get_issue_comments", name: "Invalid issue", args: map[string]interface{}{ "issue": "NONEXISTENT-123", }, }, { handler: "get_issue_comments", name: "With limit", args: map[string]interface{}{ "issue": ISSUE_ID, "limit": float64(3), }, }, { handler: "get_issue_comments", name: "With_thread_parameter", args: map[string]interface{}{ "issue": ISSUE_ID, "thread": "ae3d62d6-3f40-4990-867b-5c97dd265a40", // ID of a comment to get replies for }, }, { handler: "get_issue_comments", name: "Thread_with_pagination", args: map[string]interface{}{ "issue": ISSUE_ID, "thread": "ae3d62d6-3f40-4990-867b-5c97dd265a40", // ID of a comment to get replies for "limit": float64(2), }, }, // AddCommentHandler test cases { handler: "add_comment", name: "Valid comment", write: true, args: map[string]interface{}{ "issue": ISSUE_ID, "body": "Test comment", }, }, { handler: "add_comment", name: "Reply_to_comment", write: true, args: map[string]interface{}{ "issue": ISSUE_ID, "body": "This is a reply to the comment", "thread": "ae3d62d6-3f40-4990-867b-5c97dd265a40", // ID of the comment to reply to }, }, { handler: "add_comment", name: "Missing issue", args: map[string]interface{}{ "body": "Test comment", }, }, { handler: "add_comment", name: "Missing body", args: map[string]interface{}{ "issue": ISSUE_ID, }, }, { handler: "add_comment", name: "Reply with URL", write: true, args: map[string]interface{}{ "issue": ISSUE_ID, "body": "Reply using comment URL", "thread": "https://linear.app/linear-mcp-go-test/issue/TEST-10/updated-test-issue#comment-ae3d62d6", }, }, { handler: "add_comment", name: "Reply with shorthand", write: true, args: map[string]interface{}{ "issue": ISSUE_ID, "body": "Reply using shorthand", "thread": "comment-ae3d62d6", }, }, // ReplyToCommentHandler test cases { handler: "reply_to_comment", name: "Valid reply", write: true, args: map[string]interface{}{ "thread": "ae3d62d6-3f40-4990-867b-5c97dd265a40", "body": "This is a reply using the dedicated tool", }, }, { handler: "reply_to_comment", name: "Reply with URL", write: true, args: map[string]interface{}{ "thread": "https://linear.app/linear-mcp-go-test/issue/TEST-10/updated-test-issue#comment-ae3d62d6", "body": "Reply using URL in dedicated tool", }, }, { handler: "reply_to_comment", name: "Missing thread", args: map[string]interface{}{ "body": "Reply without thread", }, }, { handler: "reply_to_comment", name: "Missing body", args: map[string]interface{}{ "thread": "ae3d62d6-3f40-4990-867b-5c97dd265a40", }, }, // UpdateCommentHandler test cases { handler: "update_comment", name: "Valid comment update", write: true, args: map[string]interface{}{ "comment": "ae3d62d6-3f40-4990-867b-5c97dd265a40", "body": "Updated comment text", }, }, { handler: "update_comment", name: "Valid comment update with shorthand", write: true, args: map[string]interface{}{ "comment": "comment-ae3d62d6", "body": "Updated comment text via shorthand", }, }, { handler: "update_comment", name: "Valid comment update with hash only", write: true, args: map[string]interface{}{ "comment": "ae3d62d6", "body": "Updated comment text via hash", }, }, { handler: "update_comment", name: "Missing comment", args: map[string]interface{}{ "body": "Updated comment text", }, }, { handler: "update_comment", name: "Missing body", args: map[string]interface{}{ "comment": "ae3d62d6-3f40-4990-867b-5c97dd265a40", }, }, { handler: "update_comment", name: "Invalid comment identifier", write: true, args: map[string]interface{}{ "comment": "invalid-comment-id", "body": "Updated comment text", }, }, // GetProjectHandler test cases { handler: "get_project", name: "By ID", args: map[string]interface{}{ "project": "01bff2dd-ab7f-4464-b425-97073862013f", }, }, { handler: "get_project", name: "Missing project param", args: map[string]interface{}{}, }, { handler: "get_project", name: "Invalid project", args: map[string]interface{}{ "project": "NONEXISTENT-PROJECT", }, }, { handler: "get_project", name: "By slug", args: map[string]interface{}{ "project": "mcp-tool-investigation-ae44897e42a7", }, }, { handler: "get_project", name: "By name", args: map[string]interface{}{ "project": "MCP tool investigation", }, }, { handler: "get_project", name: "Non-existent slug", args: map[string]interface{}{ "project": "non-existent-slug", }, }, // SearchProjectsHandler test cases { handler: "search_projects", name: "Empty query", args: map[string]interface{}{ "query": "", }, }, { handler: "search_projects", name: "No results", args: map[string]interface{}{ "query": "non-existent-project-query", }, }, { handler: "search_projects", name: "Multiple results", args: map[string]interface{}{ "query": "MCP", }, }, // CreateProjectHandler test cases { handler: "create_project", name: "Valid project", args: map[string]interface{}{ "name": "Created Test Project", "teamIds": TEAM_ID, }, write: true, }, { handler: "create_project", name: "With all optional fields", args: map[string]interface{}{ "name": "Test Project 2", "teamIds": TEAM_ID, "description": "Test Description", "leadId": USER_ID, "startDate": "2024-01-01", "targetDate": "2024-12-31", }, write: true, }, { handler: "create_project", name: "Missing name", args: map[string]interface{}{ "teamIds": TEAM_ID, }, write: true, }, { handler: "create_project", name: "Invalid team ID", args: map[string]interface{}{ "name": "Test Project 3", "teamIds": "invalid-team-id", }, write: true, }, // UpdateProjectHandler test cases { handler: "update_project", name: "Valid update", args: map[string]interface{}{ "project": UPDATE_PROJECT_ID, "name": "Updated Project Name", }, write: true, }, { handler: "update_project", name: "Update name and description", args: map[string]interface{}{ "project": UPDATE_PROJECT_ID, "name": "Updated Project Name 2", "description": "Updated Description", }, write: true, }, { handler: "update_project", name: "Non-existent project", args: map[string]interface{}{ "project": "non-existent-project", "name": "Updated Project Name", }, write: true, }, { handler: "update_project", name: "Update only description", args: map[string]interface{}{ "project": UPDATE_PROJECT_ID, "description": "Updated Description Only", }, write: true, }, // GetMilestoneHandler test cases { handler: "get_milestone", name: "Valid milestone", args: map[string]interface{}{ "milestone": MILESTONE_ID, }, }, { handler: "get_milestone", name: "By name", args: map[string]interface{}{ "milestone": "Test Milestone 2", }, }, { handler: "get_milestone", name: "Non-existent milestone", args: map[string]interface{}{ "milestone": "non-existent-milestone", }, }, // CreateMilestoneHandler test cases { handler: "create_milestone", name: "Valid milestone", args: map[string]interface{}{ "name": "Test Milestone 2.2", "projectId": UPDATE_PROJECT_ID, }, write: true, }, { handler: "create_milestone", name: "With all optional fields", args: map[string]interface{}{ "name": "Test Milestone 3.2", "projectId": UPDATE_PROJECT_ID, "description": "Test Description", "targetDate": "2024-12-31", }, write: true, }, { handler: "create_milestone", name: "Missing name", args: map[string]interface{}{ "projectId": UPDATE_PROJECT_ID, }, write: true, }, { handler: "create_milestone", name: "Invalid project ID", args: map[string]interface{}{ "name": "Test Milestone 3.1", "projectId": "invalid-project-id", }, write: true, }, // UpdateMilestoneHandler test cases { handler: "update_milestone", name: "Valid update", args: map[string]interface{}{ "milestone": UPDATE_MILESTONE_ID, "name": "Updated Milestone Name 22", "description": "Updated Description", "targetDate": "2025-01-01", }, write: true, }, { handler: "update_milestone", name: "Non-existent milestone", args: map[string]interface{}{ "milestone": "non-existent-milestone", "name": "Updated Milestone Name", }, write: true, }, // GetInitiativeHandler test cases { handler: "get_initiative", name: "Valid initiative", args: map[string]interface{}{ "initiative": INITIATIVE_ID, }, }, { handler: "get_initiative", name: "By name", args: map[string]interface{}{ "initiative": "Push for MCP", }, }, { handler: "get_initiative", name: "Non-existent name", args: map[string]interface{}{ "initiative": "non-existent-name", }, }, // CreateInitiativeHandler test cases { handler: "create_initiative", name: "Valid initiative", args: map[string]interface{}{ "name": "Created Test Initiative", }, write: true, }, { handler: "create_initiative", name: "With description", args: map[string]interface{}{ "name": "Created Test Initiative 2", "description": "Test Description", }, write: true, }, { handler: "create_initiative", name: "Missing name", args: map[string]interface{}{}, write: true, }, // UpdateInitiativeHandler test cases { handler: "update_initiative", name: "Valid update", args: map[string]interface{}{ "initiative": UPDATE_INITIATIVE_ID, "name": "Updated Initiative Name", "description": "Updated Description", }, write: true, }, { handler: "update_initiative", name: "Non-existent initiative", args: map[string]interface{}{ "initiative": "non-existent-initiative", "name": "Updated Initiative Name", }, write: true, }, } for _, tt := range tests { t.Run(tt.handler+"_"+tt.name, func(t *testing.T) { if tt.write && *record && !*recordWrites { t.Skip("Skipping write test when recordWrites=false") return } // Generate golden file path goldenPath := filepath.Join("../../testdata/golden", tt.handler+"_handler_"+tt.name+".golden") // Create test client client, cleanup := linear.NewTestClient(t, tt.handler+"_handler_"+tt.name, *record || *recordWrites) defer cleanup() // Create the appropriate handler based on tt.handler var handler func(context.Context, mcp.CallToolRequest) (*mcp.CallToolResult, error) switch tt.handler { case "get_teams": handler = tools.GetTeamsHandler(client) case "create_issue": handler = tools.CreateIssueHandler(client) case "update_issue": handler = tools.UpdateIssueHandler(client) case "search_issues": handler = tools.SearchIssuesHandler(client) case "get_user_issues": handler = tools.GetUserIssuesHandler(client) case "get_issue": handler = tools.GetIssueHandler(client) case "get_issue_comments": handler = tools.GetIssueCommentsHandler(client) case "add_comment": handler = tools.AddCommentHandler(client) case "reply_to_comment": handler = tools.ReplyToCommentHandler(client) case "update_comment": handler = tools.UpdateCommentHandler(client) case "get_project": handler = tools.GetProjectHandler(client) case "search_projects": handler = tools.SearchProjectsHandler(client) case "create_project": handler = tools.CreateProjectHandler(client) case "update_project": handler = tools.UpdateProjectHandler(client) case "get_milestone": handler = tools.GetMilestoneHandler(client) case "create_milestone": handler = tools.CreateMilestoneHandler(client) case "update_milestone": handler = tools.UpdateMilestoneHandler(client) case "get_initiative": handler = tools.GetInitiativeHandler(client) case "create_initiative": handler = tools.CreateInitiativeHandler(client) case "update_initiative": handler = tools.UpdateInitiativeHandler(client) default: t.Fatalf("Unknown handler type: %s", tt.handler) } // Create the request request := mcp.CallToolRequest{} request.Params.Name = "linear_" + tt.handler request.Params.Arguments = tt.args // Call the handler result, err := handler(context.Background(), request) // Check for errors if err != nil { t.Fatalf("Handler returned error: %v", err) } // Extract the actual output and error var actualOutput, actualErr string if result.IsError { // For error results, the error message is in the text content for _, content := range result.Content { if textContent, ok := content.(mcp.TextContent); ok { actualErr = textContent.Text break } } } else { // For success results, the output is in the text content for _, content := range result.Content { if textContent, ok := content.(mcp.TextContent); ok { actualOutput = textContent.Text break } } } // If golden flag is set, update the golden file if *golden { writeGoldenFile(t, goldenPath, expectation{ Err: actualErr, Output: actualOutput, }) return } // Otherwise, read the golden file and compare expected := readGoldenFile(t, goldenPath) // Compare error if diff := cmp.Diff(expected.Err, actualErr); diff != "" { t.Errorf("Error mismatch (-want +got):\n%s", diff) } // Compare output (only if no error is expected) if expected.Err == "" { if diff := cmp.Diff(expected.Output, actualOutput); diff != "" { t.Errorf("Output mismatch (-want +got):\n%s", diff) } } }) } } // readGoldenFile and writeGoldenFile are defined in test_helpers.go ``` -------------------------------------------------------------------------------- /cmd/setup_test.go: -------------------------------------------------------------------------------- ```go package cmd import ( "bytes" "encoding/json" "fmt" "io" "os" "os/exec" "path/filepath" "sort" "strings" "testing" "github.com/google/go-cmp/cmp" ) // Define expectation types type fileExpectation struct { path string content string mustExist bool } type preExistingFile struct { path string content string } type expectations struct { files map[string]fileExpectation errors []string exitCode int } // TestSetupCommand tests the setup command with various combinations of parameters func TestSetupCommand(t *testing.T) { // Build the binary binaryPath, err := buildBinary() if err != nil { t.Fatalf("Failed to build binary: %v", err) } defer os.RemoveAll(filepath.Dir(binaryPath)) // Define test cases testCases := []struct { name string toolParam string writeAccess bool autoApprove string projectPath string preExistingFiles map[string]preExistingFile expect expectations }{ { name: "Cline Only", toolParam: "cline", writeAccess: true, autoApprove: "allow-read-only", expect: expectations{ files: map[string]fileExpectation{ "cline": { path: "home/.vscode-server/data/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json", mustExist: true, content: `{ "mcpServers": { "linear": { "command": "home/mcp-servers/linear-mcp-go", "args": ["serve", "--write-access=true"], "env": { "LINEAR_API_KEY": "test-api-key" }, "autoApprove": ["linear_get_initiative", "linear_get_issue", "linear_get_issue_comments", "linear_get_milestone", "linear_get_project", "linear_get_teams", "linear_get_user_issues", "linear_search_issues", "linear_search_projects"], "disabled": false } } }`, }, }, exitCode: 0, }, }, { name: "Roo Code Only", toolParam: "roo-code", writeAccess: true, autoApprove: "allow-read-only", expect: expectations{ files: map[string]fileExpectation{ "roo-code": { path: "home/.vscode-server/data/User/globalStorage/rooveterinaryinc.roo-cline/settings/cline_mcp_settings.json", mustExist: true, content: `{ "mcpServers": { "linear": { "command": "home/mcp-servers/linear-mcp-go", "args": ["serve", "--write-access=true"], "env": { "LINEAR_API_KEY": "test-api-key" }, "autoApprove": ["linear_get_initiative", "linear_get_issue", "linear_get_issue_comments", "linear_get_milestone", "linear_get_project", "linear_get_teams", "linear_get_user_issues", "linear_search_issues", "linear_search_projects"], "disabled": false } } }`, }, }, exitCode: 0, }, }, { name: "Multiple Tools", toolParam: "cline,roo-code", writeAccess: true, autoApprove: "allow-read-only", expect: expectations{ files: map[string]fileExpectation{ "cline": { path: "home/.vscode-server/data/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json", mustExist: true, content: `{ "mcpServers": { "linear": { "command": "home/mcp-servers/linear-mcp-go", "args": ["serve", "--write-access=true"], "env": { "LINEAR_API_KEY": "test-api-key" }, "autoApprove": ["linear_get_initiative", "linear_get_issue", "linear_get_issue_comments", "linear_get_milestone", "linear_get_project", "linear_get_teams", "linear_get_user_issues", "linear_search_issues", "linear_search_projects"], "disabled": false } } }`, }, "roo-code": { path: "home/.vscode-server/data/User/globalStorage/rooveterinaryinc.roo-cline/settings/cline_mcp_settings.json", mustExist: true, content: `{ "mcpServers": { "linear": { "command": "home/mcp-servers/linear-mcp-go", "args": ["serve", "--write-access=true"], "env": { "LINEAR_API_KEY": "test-api-key" }, "autoApprove": ["linear_get_initiative", "linear_get_issue", "linear_get_issue_comments", "linear_get_milestone", "linear_get_project", "linear_get_teams", "linear_get_user_issues", "linear_search_issues", "linear_search_projects"], "disabled": false } } }`, }, }, exitCode: 0, }, }, { name: "Invalid Tool", toolParam: "invalid-tool,cline", writeAccess: true, autoApprove: "allow-read-only", expect: expectations{ files: map[string]fileExpectation{ "cline": { path: "home/.vscode-server/data/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json", mustExist: true, content: `{ "mcpServers": { "linear": { "command": "home/mcp-servers/linear-mcp-go", "args": ["serve", "--write-access=true"], "env": { "LINEAR_API_KEY": "test-api-key" }, "autoApprove": ["linear_get_initiative", "linear_get_issue", "linear_get_issue_comments", "linear_get_milestone", "linear_get_project", "linear_get_teams", "linear_get_user_issues", "linear_search_issues", "linear_search_projects"], "disabled": false } } }`, }, }, errors: []string{"Unsupported tool: invalid-tool"}, exitCode: 1, }, }, { name: "Preserve Existing Arrays in Config", toolParam: "cline", writeAccess: true, autoApprove: "allow-read-only", preExistingFiles: map[string]preExistingFile{ "cline": { path: "home/.vscode-server/data/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json", content: `{ "mcpServers": { "existing-server": { "command": "/path/to/existing/server", "args": ["serve", "--option1", "--option2"], "autoApprove": ["tool1", "tool2", "tool3"], "env": { "API_KEY": "existing-key" }, "disabled": false, "customArray": ["item1", "item2"], "nestedObject": { "arrayField": ["nested1", "nested2"] } } }, "otherTopLevelArray": ["value1", "value2"], "otherConfig": { "someArray": [1, 2, 3] } }`, }, }, expect: expectations{ files: map[string]fileExpectation{ "cline": { path: "home/.vscode-server/data/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json", mustExist: true, content: `{ "mcpServers": { "existing-server": { "command": "/path/to/existing/server", "args": ["serve", "--option1", "--option2"], "autoApprove": ["tool1", "tool2", "tool3"], "env": { "API_KEY": "existing-key" }, "disabled": false, "customArray": ["item1", "item2"], "nestedObject": { "arrayField": ["nested1", "nested2"] } }, "linear": { "command": "home/mcp-servers/linear-mcp-go", "args": ["serve", "--write-access=true"], "env": { "LINEAR_API_KEY": "test-api-key" }, "autoApprove": ["linear_get_initiative", "linear_get_issue", "linear_get_issue_comments", "linear_get_milestone", "linear_get_project", "linear_get_teams", "linear_get_user_issues", "linear_search_issues", "linear_search_projects"], "disabled": false } }, "otherTopLevelArray": ["value1", "value2"], "otherConfig": { "someArray": [1, 2, 3] } }`, }, }, exitCode: 0, }, }, { name: "Complex Array Preservation Test", toolParam: "cline", writeAccess: false, autoApprove: "linear_get_issue,linear_search_issues", preExistingFiles: map[string]preExistingFile{ "cline": { path: "home/.vscode-server/data/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json", content: `{ "mcpServers": { "github": { "command": "npx", "args": ["-y", "@modelcontextprotocol/server-github"], "env": { "GITHUB_PERSONAL_ACCESS_TOKEN": "github_token" }, "autoApprove": ["search_repositories", "get_file_contents"], "disabled": false }, "filesystem": { "command": "npx", "args": ["-y", "@modelcontextprotocol/server-filesystem", "/workspace"], "autoApprove": [], "disabled": false } }, "globalSettings": { "enabledFeatures": ["autocomplete", "syntax-highlighting"], "debugModes": ["verbose", "trace"] } }`, }, }, expect: expectations{ files: map[string]fileExpectation{ "cline": { path: "home/.vscode-server/data/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json", mustExist: true, content: `{ "mcpServers": { "github": { "command": "npx", "args": ["-y", "@modelcontextprotocol/server-github"], "env": { "GITHUB_PERSONAL_ACCESS_TOKEN": "github_token" }, "autoApprove": ["search_repositories", "get_file_contents"], "disabled": false }, "filesystem": { "command": "npx", "args": ["-y", "@modelcontextprotocol/server-filesystem", "/workspace"], "autoApprove": [], "disabled": false }, "linear": { "command": "home/mcp-servers/linear-mcp-go", "args": ["serve"], "env": { "LINEAR_API_KEY": "test-api-key" }, "autoApprove": ["linear_get_issue", "linear_search_issues"], "disabled": false } }, "globalSettings": { "enabledFeatures": ["autocomplete", "syntax-highlighting"], "debugModes": ["verbose", "trace"] } }`, }, }, exitCode: 0, }, }, { name: "Claude Code Only", toolParam: "claude-code", projectPath: "/workspace/test-project", expect: expectations{ files: map[string]fileExpectation{ "claude-code": { path: "home/.claude.json", mustExist: true, content: `{ "projects": { "/workspace/test-project": { "mcpServers": { "linear": { "type": "stdio", "command": "home/mcp-servers/linear-mcp-go", "args": ["serve"], "env": { "LINEAR_API_KEY": "test-api-key" }, "autoApprove": [], "disabled": false } } } } }`, }, }, exitCode: 0, }, }, { name: "Claude Code with Existing File", toolParam: "claude-code", projectPath: "/workspace/test-project", preExistingFiles: map[string]preExistingFile{ "claude-code": { path: "home/.claude.json", content: `{ "projects": { "/workspace/another-project": { "mcpServers": { "another-server": { "command": "/path/to/another/server" } } } } }`, }, }, expect: expectations{ files: map[string]fileExpectation{ "claude-code": { path: "home/.claude.json", mustExist: true, content: `{ "projects": { "/workspace/another-project": { "mcpServers": { "another-server": { "command": "/path/to/another/server" } } }, "/workspace/test-project": { "mcpServers": { "linear": { "type": "stdio", "command": "home/mcp-servers/linear-mcp-go", "args": ["serve"], "env": { "LINEAR_API_KEY": "test-api-key" }, "autoApprove": [], "disabled": false } } } } }`, }, }, exitCode: 0, }, }, { name: "Claude Code No Existing Projects and No Project Path - User Scope Registration", toolParam: "claude-code", expect: expectations{ files: map[string]fileExpectation{ "claude-code": { path: "home/.claude.json", mustExist: true, content: `{ "projects": {}, "mcpServers": { "linear": { "type": "stdio", "command": "home/mcp-servers/linear-mcp-go", "args": ["serve"], "env": { "LINEAR_API_KEY": "test-api-key" }, "autoApprove": [], "disabled": false } } }`, }, }, exitCode: 0, }, }, { name: "Claude Code Register to User Scope with Existing Projects", toolParam: "claude-code", preExistingFiles: map[string]preExistingFile{ "claude-code": { path: "home/.claude.json", content: `{ "projects": { "/workspace/project1": { "mcpServers": { "existing-server": { "command": "/path/to/existing/server" } } }, "/workspace/project2": { "someOtherConfig": "value" } } }`, }, }, expect: expectations{ files: map[string]fileExpectation{ "claude-code": { path: "home/.claude.json", mustExist: true, content: `{ "projects": { "/workspace/project1": { "mcpServers": { "existing-server": { "command": "/path/to/existing/server" } } }, "/workspace/project2": { "someOtherConfig": "value" } }, "mcpServers": { "linear": { "type": "stdio", "command": "home/mcp-servers/linear-mcp-go", "args": ["serve"], "env": { "LINEAR_API_KEY": "test-api-key" }, "autoApprove": [], "disabled": false } } }`, }, }, exitCode: 0, }, }, { name: "Claude Code Multiple Project Paths", toolParam: "claude-code", projectPath: "/workspace/proj1,/workspace/proj2, /workspace/proj3 ", writeAccess: true, autoApprove: "linear_get_issue,linear_search_issues", expect: expectations{ files: map[string]fileExpectation{ "claude-code": { path: "home/.claude.json", mustExist: true, content: `{ "projects": { "/workspace/proj1": { "mcpServers": { "linear": { "type": "stdio", "command": "home/mcp-servers/linear-mcp-go", "args": ["serve", "--write-access=true"], "env": { "LINEAR_API_KEY": "test-api-key" }, "autoApprove": ["linear_get_issue", "linear_search_issues"], "disabled": false } } }, "/workspace/proj2": { "mcpServers": { "linear": { "type": "stdio", "command": "home/mcp-servers/linear-mcp-go", "args": ["serve", "--write-access=true"], "env": { "LINEAR_API_KEY": "test-api-key" }, "autoApprove": ["linear_get_issue", "linear_search_issues"], "disabled": false } } }, "/workspace/proj3": { "mcpServers": { "linear": { "type": "stdio", "command": "home/mcp-servers/linear-mcp-go", "args": ["serve", "--write-access=true"], "env": { "LINEAR_API_KEY": "test-api-key" }, "autoApprove": ["linear_get_issue", "linear_search_issues"], "disabled": false } } } } }`, }, }, exitCode: 0, }, }, { name: "Claude Code Mixed Existing and New Projects", toolParam: "claude-code", projectPath: "/workspace/existing-project,/workspace/new-project", preExistingFiles: map[string]preExistingFile{ "claude-code": { path: "home/.claude.json", content: `{ "projects": { "/workspace/existing-project": { "mcpServers": { "other-server": { "command": "/path/to/other/server" } }, "customConfig": "value" } } }`, }, }, expect: expectations{ files: map[string]fileExpectation{ "claude-code": { path: "home/.claude.json", mustExist: true, content: `{ "projects": { "/workspace/existing-project": { "mcpServers": { "other-server": { "command": "/path/to/other/server" }, "linear": { "type": "stdio", "command": "home/mcp-servers/linear-mcp-go", "args": ["serve"], "env": { "LINEAR_API_KEY": "test-api-key" }, "autoApprove": [], "disabled": false } }, "customConfig": "value" }, "/workspace/new-project": { "mcpServers": { "linear": { "type": "stdio", "command": "home/mcp-servers/linear-mcp-go", "args": ["serve"], "env": { "LINEAR_API_KEY": "test-api-key" }, "autoApprove": [], "disabled": false } } } } }`, }, }, exitCode: 0, }, }, { name: "Claude Code Empty Project Path List", toolParam: "claude-code", projectPath: " , , ", expect: expectations{ errors: []string{"no valid project paths provided"}, exitCode: 1, }, }, { name: "Claude Code Complex Settings Preservation", toolParam: "claude-code", projectPath: "/workspace/new-project", writeAccess: true, autoApprove: "linear_get_issue,linear_search_issues", preExistingFiles: map[string]preExistingFile{ "claude-code": { path: "home/.claude.json", content: `{ "firstStartTime": "2025-06-11T14:49:28.932Z", "userID": "31553dcf54399f00daf126faf48dbb0e626926f50e9bf49c16cb05c06f65cfd8", "globalSettings": { "theme": "dark", "autoSave": true, "debugMode": false, "experimentalFeatures": ["feature1", "feature2", "feature3"], "limits": { "maxTokens": 4096, "timeout": 30000, "retries": 3 }, "customMappings": { "shortcuts": { "ctrl+s": "save", "ctrl+z": "undo" }, "aliases": ["alias1", "alias2"] } }, "recentProjects": ["/workspace/project1", "/workspace/project2", "/workspace/project3"], "projects": { "/workspace/existing-project": { "allowedTools": ["tool1", "tool2", "tool3"], "history": [ { "timestamp": "2025-06-11T15:00:00.000Z", "action": "create_file", "details": {"filename": "test.js", "size": 1024} } ], "dontCrawlDirectory": false, "mcpContextUris": ["file:///workspace/docs", "https://api.example.com/docs"], "mcpServers": { "github": { "type": "stdio", "command": "npx", "args": ["-y", "@modelcontextprotocol/server-github"], "env": {"GITHUB_PERSONAL_ACCESS_TOKEN": "github_token_123"}, "autoApprove": ["search_repositories", "get_file_contents"], "disabled": false, "customConfig": {"rateLimit": 5000, "features": ["search", "read"]} }, "filesystem": { "type": "stdio", "command": "npx", "args": ["-y", "@modelcontextprotocol/server-filesystem", "/workspace"], "autoApprove": ["read_file", "list_directory"], "disabled": false, "permissions": {"read": true, "write": false, "execute": false} } }, "enabledMcpjsonServers": ["server1", "server2"], "disabledMcpjsonServers": ["server3", "server4"], "hasTrustDialogAccepted": true, "projectOnboardingSeenCount": 3, "customProjectSettings": { "linting": {"enabled": true, "rules": ["rule1", "rule2"]}, "formatting": {"tabSize": 2, "insertSpaces": true} } } }, "analytics": { "enabled": true, "sessionId": "session_12345", "metrics": {"commandsExecuted": 42, "filesModified": 15} }, "version": "1.2.3" }`, }, }, expect: expectations{ files: map[string]fileExpectation{ "claude-code": { path: "home/.claude.json", mustExist: true, content: `{ "firstStartTime": "2025-06-11T14:49:28.932Z", "userID": "31553dcf54399f00daf126faf48dbb0e626926f50e9bf49c16cb05c06f65cfd8", "globalSettings": { "theme": "dark", "autoSave": true, "debugMode": false, "experimentalFeatures": ["feature1", "feature2", "feature3"], "limits": { "maxTokens": 4096, "timeout": 30000, "retries": 3 }, "customMappings": { "shortcuts": { "ctrl+s": "save", "ctrl+z": "undo" }, "aliases": ["alias1", "alias2"] } }, "recentProjects": ["/workspace/project1", "/workspace/project2", "/workspace/project3"], "projects": { "/workspace/existing-project": { "allowedTools": ["tool1", "tool2", "tool3"], "history": [ { "timestamp": "2025-06-11T15:00:00.000Z", "action": "create_file", "details": {"filename": "test.js", "size": 1024} } ], "dontCrawlDirectory": false, "mcpContextUris": ["file:///workspace/docs", "https://api.example.com/docs"], "mcpServers": { "github": { "type": "stdio", "command": "npx", "args": ["-y", "@modelcontextprotocol/server-github"], "env": {"GITHUB_PERSONAL_ACCESS_TOKEN": "github_token_123"}, "autoApprove": ["search_repositories", "get_file_contents"], "disabled": false, "customConfig": {"rateLimit": 5000, "features": ["search", "read"]} }, "filesystem": { "type": "stdio", "command": "npx", "args": ["-y", "@modelcontextprotocol/server-filesystem", "/workspace"], "autoApprove": ["read_file", "list_directory"], "disabled": false, "permissions": {"read": true, "write": false, "execute": false} } }, "enabledMcpjsonServers": ["server1", "server2"], "disabledMcpjsonServers": ["server3", "server4"], "hasTrustDialogAccepted": true, "projectOnboardingSeenCount": 3, "customProjectSettings": { "linting": {"enabled": true, "rules": ["rule1", "rule2"]}, "formatting": {"tabSize": 2, "insertSpaces": true} } }, "/workspace/new-project": { "mcpServers": { "linear": { "type": "stdio", "command": "home/mcp-servers/linear-mcp-go", "args": ["serve", "--write-access=true"], "env": {"LINEAR_API_KEY": "test-api-key"}, "autoApprove": ["linear_get_issue", "linear_search_issues"], "disabled": false } } } }, "analytics": { "enabled": true, "sessionId": "session_12345", "metrics": {"commandsExecuted": 42, "filesModified": 15} }, "version": "1.2.3" }`, }, }, exitCode: 0, }, }, { name: "Claude Code Update Existing Linear Server", toolParam: "claude-code", projectPath: "/workspace/existing-project", writeAccess: false, autoApprove: "allow-read-only", preExistingFiles: map[string]preExistingFile{ "claude-code": { path: "home/.claude.json", content: `{ "projects": { "/workspace/existing-project": { "mcpServers": { "linear": { "type": "stdio", "command": "/old/path/to/linear", "args": ["serve", "--old-flag"], "env": {"LINEAR_API_KEY": "old-key"}, "autoApprove": ["old_tool"], "disabled": true }, "other-server": { "command": "/path/to/other/server" } } } } }`, }, }, expect: expectations{ files: map[string]fileExpectation{ "claude-code": { path: "home/.claude.json", mustExist: true, content: `{ "projects": { "/workspace/existing-project": { "mcpServers": { "linear": { "type": "stdio", "command": "home/mcp-servers/linear-mcp-go", "args": ["serve"], "env": {"LINEAR_API_KEY": "test-api-key"}, "autoApprove": ["linear_get_initiative", "linear_get_issue", "linear_get_issue_comments", "linear_get_milestone", "linear_get_project", "linear_get_teams", "linear_get_user_issues", "linear_search_issues", "linear_search_projects"], "disabled": false }, "other-server": { "command": "/path/to/other/server" } } } } }`, }, }, exitCode: 0, }, }, { name: "Claude Code User Scope with Existing User-Scoped Servers", toolParam: "claude-code", writeAccess: true, autoApprove: "linear_get_issue,linear_search_issues", preExistingFiles: map[string]preExistingFile{ "claude-code": { path: "home/.claude.json", content: `{ "projects": { "/workspace/project1": { "mcpServers": { "project-specific-server": { "command": "/path/to/project/server" } } } }, "mcpServers": { "existing-user-server": { "type": "stdio", "command": "/path/to/existing/user/server", "args": ["serve"], "env": {"API_KEY": "existing-key"}, "autoApprove": ["existing_tool"], "disabled": false } } }`, }, }, expect: expectations{ files: map[string]fileExpectation{ "claude-code": { path: "home/.claude.json", mustExist: true, content: `{ "projects": { "/workspace/project1": { "mcpServers": { "project-specific-server": { "command": "/path/to/project/server" } } } }, "mcpServers": { "existing-user-server": { "type": "stdio", "command": "/path/to/existing/user/server", "args": ["serve"], "env": {"API_KEY": "existing-key"}, "autoApprove": ["existing_tool"], "disabled": false }, "linear": { "type": "stdio", "command": "home/mcp-servers/linear-mcp-go", "args": ["serve", "--write-access=true"], "env": {"LINEAR_API_KEY": "test-api-key"}, "autoApprove": ["linear_get_issue", "linear_search_issues"], "disabled": false } } }`, }, }, exitCode: 0, }, }, { name: "Claude Code User Scope Update Existing User-Scoped Linear Server", toolParam: "claude-code", writeAccess: false, autoApprove: "allow-read-only", preExistingFiles: map[string]preExistingFile{ "claude-code": { path: "home/.claude.json", content: `{ "projects": {}, "mcpServers": { "linear": { "type": "stdio", "command": "/old/path/to/linear", "args": ["serve", "--old-flag"], "env": {"LINEAR_API_KEY": "old-key"}, "autoApprove": ["old_tool"], "disabled": true }, "other-user-server": { "command": "/path/to/other/user/server" } } }`, }, }, expect: expectations{ files: map[string]fileExpectation{ "claude-code": { path: "home/.claude.json", mustExist: true, content: `{ "projects": {}, "mcpServers": { "linear": { "type": "stdio", "command": "home/mcp-servers/linear-mcp-go", "args": ["serve"], "env": {"LINEAR_API_KEY": "test-api-key"}, "autoApprove": ["linear_get_initiative", "linear_get_issue", "linear_get_issue_comments", "linear_get_milestone", "linear_get_project", "linear_get_teams", "linear_get_user_issues", "linear_search_issues", "linear_search_projects"], "disabled": false }, "other-user-server": { "command": "/path/to/other/user/server" } } }`, }, }, exitCode: 0, }, }, { name: "Ona Only", toolParam: "ona", writeAccess: true, expect: expectations{ files: map[string]fileExpectation{ "ona": { path: ".ona/mcp-config.json", mustExist: true, content: `{ "mcpServers": { "linear": { "command": "home/mcp-servers/linear-mcp-go", "args": ["serve", "--write-access=true"], "disabled": false } } }`, }, }, exitCode: 0, }, }, { name: "Ona with Project Path", toolParam: "ona", projectPath: "/workspace/test-project", writeAccess: false, expect: expectations{ files: map[string]fileExpectation{ "ona": { path: "/workspace/test-project/.ona/mcp-config.json", mustExist: true, content: `{ "mcpServers": { "linear": { "command": "home/mcp-servers/linear-mcp-go", "args": ["serve"], "disabled": false } } }`, }, }, exitCode: 0, }, }, { name: "Ona with Existing Config", toolParam: "ona", writeAccess: true, preExistingFiles: map[string]preExistingFile{ "ona": { path: ".ona/mcp-config.json", content: `{ "mcpServers": { "playwright": { "name": "playwright", "command": "npx", "args": ["-y", "@executeautomation/playwright-mcp-server"] } } }`, }, }, expect: expectations{ files: map[string]fileExpectation{ "ona": { path: ".ona/mcp-config.json", mustExist: true, content: `{ "mcpServers": { "playwright": { "name": "playwright", "command": "npx", "args": ["-y", "@executeautomation/playwright-mcp-server"] }, "linear": { "command": "home/mcp-servers/linear-mcp-go", "args": ["serve", "--write-access=true"], "disabled": false } } }`, }, }, exitCode: 0, }, }, { name: "Ona with Complex Nested Config", toolParam: "ona", writeAccess: false, preExistingFiles: map[string]preExistingFile{ "ona": { path: ".ona/mcp-config.json", content: `{ "version": "1.0.0", "metadata": { "created": "2024-01-01T00:00:00Z", "author": "test-user", "tags": ["development", "testing", "automation"], "config": { "nested": { "deeply": { "properties": ["value1", "value2", "value3"], "settings": { "enabled": true, "timeout": 30000, "retries": 3, "features": { "advanced": { "caching": true, "compression": false, "encryption": { "algorithm": "AES-256", "keySize": 256, "modes": ["CBC", "GCM", "CTR"] } } } } } } } }, "mcpServers": { "custom-server": { "name": "custom-server", "command": "/usr/local/bin/custom-mcp-server", "args": ["--mode", "production", "--verbose"], "env": { "CUSTOM_API_KEY": "secret-key-123", "CUSTOM_ENDPOINT": "https://api.example.com/v1" }, "timeout": 60000, "retries": 5, "features": { "streaming": true, "batching": false, "compression": { "enabled": true, "algorithm": "gzip", "level": 6 } }, "customArrays": { "supportedFormats": ["json", "xml", "yaml"], "allowedOrigins": ["localhost", "*.example.com", "api.test.com"], "permissions": ["read", "write", "execute"] }, "nestedConfig": { "database": { "connection": { "host": "localhost", "port": 5432, "ssl": { "enabled": true, "cert": "/path/to/cert.pem", "key": "/path/to/key.pem", "ca": "/path/to/ca.pem" } }, "pool": { "min": 5, "max": 20, "idle": 300 } } } } }, "globalSettings": { "logLevel": "info", "enableMetrics": true, "metricsConfig": { "endpoint": "http://metrics.example.com:9090", "interval": 30, "labels": { "environment": "test", "service": "mcp-server", "version": "1.0.0" } } } }`, }, }, expect: expectations{ files: map[string]fileExpectation{ "ona": { path: ".ona/mcp-config.json", mustExist: true, content: `{ "version": "1.0.0", "metadata": { "created": "2024-01-01T00:00:00Z", "author": "test-user", "tags": ["development", "testing", "automation"], "config": { "nested": { "deeply": { "properties": ["value1", "value2", "value3"], "settings": { "enabled": true, "timeout": 30000, "retries": 3, "features": { "advanced": { "caching": true, "compression": false, "encryption": { "algorithm": "AES-256", "keySize": 256, "modes": ["CBC", "GCM", "CTR"] } } } } } } } }, "mcpServers": { "custom-server": { "name": "custom-server", "command": "/usr/local/bin/custom-mcp-server", "args": ["--mode", "production", "--verbose"], "env": { "CUSTOM_API_KEY": "secret-key-123", "CUSTOM_ENDPOINT": "https://api.example.com/v1" }, "timeout": 60000, "retries": 5, "features": { "streaming": true, "batching": false, "compression": { "enabled": true, "algorithm": "gzip", "level": 6 } }, "customArrays": { "supportedFormats": ["json", "xml", "yaml"], "allowedOrigins": ["localhost", "*.example.com", "api.test.com"], "permissions": ["read", "write", "execute"] }, "nestedConfig": { "database": { "connection": { "host": "localhost", "port": 5432, "ssl": { "enabled": true, "cert": "/path/to/cert.pem", "key": "/path/to/key.pem", "ca": "/path/to/ca.pem" } }, "pool": { "min": 5, "max": 20, "idle": 300 } } } }, "linear": { "command": "home/mcp-servers/linear-mcp-go", "args": ["serve"], "disabled": false } }, "globalSettings": { "logLevel": "info", "enableMetrics": true, "metricsConfig": { "endpoint": "http://metrics.example.com:9090", "interval": 30, "labels": { "environment": "test", "service": "mcp-server", "version": "1.0.0" } } } }`, }, }, exitCode: 0, }, }, } // Run each test case for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { // Create a temporary directory rootDir, err := os.MkdirTemp("", "linear-mcp-go-test-*") if err != nil { t.Fatalf("Failed to create temp dir: %v", err) } defer os.RemoveAll(rootDir) // Set up the directory structure homeDir := filepath.Join(rootDir, "home") // Copy the binary to the temp directory tempBinaryPath := filepath.Join(rootDir, "linear-mcp-go") if err := copyFile(binaryPath, tempBinaryPath); err != nil { t.Fatalf("Failed to copy binary: %v", err) } if err := os.Chmod(tempBinaryPath, 0755); err != nil { t.Fatalf("Failed to make binary executable: %v", err) } // Set the HOME environment variable oldHome := os.Getenv("HOME") os.Setenv("HOME", homeDir) defer os.Setenv("HOME", oldHome) // Set the LINEAR_API_KEY environment variable oldApiKey := os.Getenv("LINEAR_API_KEY") os.Setenv("LINEAR_API_KEY", "test-api-key") defer os.Setenv("LINEAR_API_KEY", oldApiKey) // Create pre-existing files if specified for _, preFile := range tc.preExistingFiles { fullPath := filepath.Join(rootDir, preFile.path) if err := os.MkdirAll(filepath.Dir(fullPath), 0755); err != nil { t.Fatalf("Failed to create directory for pre-existing file %s: %v", fullPath, err) } if err := os.WriteFile(fullPath, []byte(preFile.content), 0644); err != nil { t.Fatalf("Failed to create pre-existing file %s: %v", fullPath, err) } } // Build the command args := []string{"setup", "--tool=" + tc.toolParam} if tc.writeAccess { args = append(args, "--write-access=true") } if tc.autoApprove != "" { args = append(args, "--auto-approve="+tc.autoApprove) } if tc.projectPath != "" { args = append(args, "--project-path="+tc.projectPath) } // Execute the command cmd := exec.Command(tempBinaryPath, args...) cmd.Dir = rootDir // Set working directory to the test root var stdout, stderr bytes.Buffer cmd.Stdout = &stdout cmd.Stderr = &stderr err = cmd.Run() // Check exit code exitCode := 0 if err != nil { if exitError, ok := err.(*exec.ExitError); ok { exitCode = exitError.ExitCode() } else { t.Fatalf("Failed to run command: %v", err) } } // Verify exit code if exitCode != tc.expect.exitCode { t.Errorf("Expected exit code %d, got %d", tc.expect.exitCode, exitCode) } // Verify expected files verifyFileExpectations(t, rootDir, tc.expect.files) // Verify expected errors in output output := stdout.String() + stderr.String() for _, expectedError := range tc.expect.errors { if !strings.Contains(output, expectedError) { t.Errorf("Expected output to contain '%s', got: %s", expectedError, output) } } }) } } // Helper function to verify file expectations func verifyFileExpectations(t *testing.T, rootDir string, fileExpects map[string]fileExpectation) { for tool, expect := range fileExpects { filePath := filepath.Join(rootDir, expect.path) // Check if file exists _, err := os.Stat(filePath) if os.IsNotExist(err) { if expect.mustExist { t.Errorf("Expected file %s was not created for %s", filePath, tool) } continue } // File exists, verify content if expected if expect.content != "" { actualContent, err := os.ReadFile(filePath) if err != nil { t.Fatalf("Failed to read configuration file %s: %v", filePath, err) } // Parse both expected and actual content as JSON for comparison var expectedJSON, actualJSON map[string]interface{} if err := json.Unmarshal([]byte(expect.content), &expectedJSON); err != nil { t.Fatalf("Failed to parse expected JSON for %s: %v", tool, err) } if err := json.Unmarshal(actualContent, &actualJSON); err != nil { t.Fatalf("Failed to parse actual JSON in file %s: %v", filePath, err) } // Process the JSON objects to make them comparable normalizeJSON(expectedJSON) normalizeJSON(actualJSON) // Compare the JSON objects if diff := cmp.Diff(expectedJSON, actualJSON); diff != "" { t.Errorf("File content mismatch for %s (-want +got):\n%s", tool, diff) } } } } // normalizeJSON processes a JSON object to make it comparable // by removing fields that may vary and sorting arrays func normalizeJSON(jsonObj map[string]interface{}) { normalizeCommandPaths(jsonObj) normalizeJSONRecursive(jsonObj) } // normalizeCommandPaths normalizes command paths in server configurations func normalizeCommandPaths(obj interface{}) { switch v := obj.(type) { case map[string]interface{}: // Look for server configuration containers if isServerContainer(v) { for _, serverConfig := range v { if serverMap, ok := serverConfig.(map[string]interface{}); ok { // Normalize the command field by stripping temporary directory prefix if command, ok := serverMap["command"].(string); ok { // Strip the temporary test directory prefix, keeping only the meaningful part // Pattern: /tmp/linear-mcp-go-test-*/home/... -> home/... if strings.Contains(command, "/home/") { parts := strings.Split(command, "/home/") if len(parts) > 1 { serverMap["command"] = "home/" + parts[1] } } } } } } // Process nested objects recursively for _, value := range v { normalizeCommandPaths(value) } case []interface{}: // Process array elements recursively for _, item := range v { normalizeCommandPaths(item) } } } // isServerContainer checks if a map contains server configurations func isServerContainer(m map[string]interface{}) bool { // Check if this looks like a server container by examining its values for _, value := range m { if serverMap, ok := value.(map[string]interface{}); ok { // If it has command field, it's likely a server config container if _, hasCommand := serverMap["command"]; hasCommand { return true } } } return false } // normalizeJSONRecursive recursively processes JSON objects to normalize them for comparison func normalizeJSONRecursive(obj interface{}) { switch v := obj.(type) { case map[string]interface{}: // Process all map entries recursively for _, value := range v { normalizeJSONRecursive(value) } case []interface{}: // Sort arrays if they contain strings if len(v) > 0 { // Check if all elements are strings allStrings := true for _, item := range v { if _, ok := item.(string); !ok { allStrings = false break } } if allStrings { // Convert to string slice, sort, and convert back strSlice := make([]string, len(v)) for i, item := range v { strSlice[i] = item.(string) } sort.Strings(strSlice) // Update the original slice in place for i, str := range strSlice { v[i] = str } } } // Process array elements recursively for _, item := range v { normalizeJSONRecursive(item) } } } // Helper function to build the binary func buildBinary() (string, error) { // Create a temporary directory for the binary tempDir, err := os.MkdirTemp("", "linear-mcp-go-build-*") if err != nil { return "", fmt.Errorf("failed to create temp dir: %w", err) } // Get the project root directory (parent of cmd directory) currentDir, err := os.Getwd() if err != nil { os.RemoveAll(tempDir) return "", fmt.Errorf("failed to get current directory: %w", err) } // Ensure we're building from the project root projectRoot := filepath.Dir(currentDir) if filepath.Base(currentDir) != "cmd" { // If we're already in the project root, use the current directory projectRoot = currentDir } fmt.Printf("Building binary from project root: %s\n", projectRoot) // Build the binary binaryPath := filepath.Join(tempDir, "linear-mcp-go") cmd := exec.Command("go", "build", "-o", binaryPath) cmd.Dir = projectRoot // Set the working directory to the project root var stdout, stderr bytes.Buffer cmd.Stdout = &stdout cmd.Stderr = &stderr if err := cmd.Run(); err != nil { os.RemoveAll(tempDir) return "", fmt.Errorf("failed to build binary: %w\nstdout: %s\nstderr: %s", err, stdout.String(), stderr.String()) } // Verify the binary exists and is executable info, err := os.Stat(binaryPath) if err != nil { os.RemoveAll(tempDir) return "", fmt.Errorf("failed to stat binary: %w", err) } if info.Size() == 0 { os.RemoveAll(tempDir) return "", fmt.Errorf("binary file is empty") } // Make sure the binary is executable if err := os.Chmod(binaryPath, 0755); err != nil { os.RemoveAll(tempDir) return "", fmt.Errorf("failed to make binary executable: %w", err) } fmt.Printf("Successfully built binary at %s (size: %d bytes)\n", binaryPath, info.Size()) return binaryPath, nil } // Helper function to copy a file func copyFile(src, dst string) error { sourceFile, err := os.Open(src) if err != nil { return fmt.Errorf("failed to open source file: %w", err) } defer sourceFile.Close() destFile, err := os.Create(dst) if err != nil { return fmt.Errorf("failed to create destination file: %w", err) } defer destFile.Close() if _, err := io.Copy(destFile, sourceFile); err != nil { return fmt.Errorf("failed to copy file: %w", err) } return nil } // TestOnaNewlinePreservation specifically tests that the Ona setup preserves newlines and empty lines func TestOnaNewlinePreservation(t *testing.T) { // Build the binary binaryPath, err := buildBinary() if err != nil { t.Fatalf("Failed to build binary: %v", err) } defer os.RemoveAll(filepath.Dir(binaryPath)) // Create a temporary directory rootDir, err := os.MkdirTemp("", "linear-mcp-go-newline-test-*") if err != nil { t.Fatalf("Failed to create temp dir: %v", err) } defer os.RemoveAll(rootDir) // Set up the directory structure homeDir := filepath.Join(rootDir, "home") configDir := filepath.Join(rootDir, ".ona") configPath := filepath.Join(configDir, "mcp-config.json") // Create the config directory if err := os.MkdirAll(configDir, 0755); err != nil { t.Fatalf("Failed to create config directory: %v", err) } // Create pre-existing config with trailing newlines and empty lines originalContent := `{ "mcpServers": { "playwright": { "name": "playwright", "command": "npx", "args": ["-y", "@executeautomation/playwright-mcp-server"] } } } ` if err := os.WriteFile(configPath, []byte(originalContent), 0644); err != nil { t.Fatalf("Failed to create pre-existing config: %v", err) } // Copy the binary to the temp directory tempBinaryPath := filepath.Join(rootDir, "linear-mcp-go") if err := copyFile(binaryPath, tempBinaryPath); err != nil { t.Fatalf("Failed to copy binary: %v", err) } if err := os.Chmod(tempBinaryPath, 0755); err != nil { t.Fatalf("Failed to make binary executable: %v", err) } // Set environment variables oldHome := os.Getenv("HOME") os.Setenv("HOME", homeDir) defer os.Setenv("HOME", oldHome) oldApiKey := os.Getenv("LINEAR_API_KEY") os.Setenv("LINEAR_API_KEY", "test-api-key") defer os.Setenv("LINEAR_API_KEY", oldApiKey) // Execute the setup command cmd := exec.Command(tempBinaryPath, "setup", "--tool=ona", "--write-access=true") cmd.Dir = rootDir var stdout, stderr bytes.Buffer cmd.Stdout = &stdout cmd.Stderr = &stderr err = cmd.Run() if err != nil { t.Fatalf("Setup command failed: %v\nStdout: %s\nStderr: %s", err, stdout.String(), stderr.String()) } // Read the updated config file updatedContent, err := os.ReadFile(configPath) if err != nil { t.Fatalf("Failed to read updated config: %v", err) } // Check if the trailing newlines are preserved updatedStr := string(updatedContent) if !strings.HasSuffix(updatedStr, "\n\n\n") { t.Errorf("Expected config to end with three newlines, but got:\n%q", updatedStr[len(updatedStr)-10:]) t.Errorf("Full updated content:\n%q", updatedStr) } // Verify the JSON is still valid var config map[string]interface{} if err := json.Unmarshal(updatedContent, &config); err != nil { t.Fatalf("Updated config is not valid JSON: %v", err) } // Verify the linear server was added mcpServers, ok := config["mcpServers"].(map[string]interface{}) if !ok { t.Fatalf("mcpServers not found in config") } linear, ok := mcpServers["linear"].(map[string]interface{}) if !ok { t.Fatalf("linear server not found in mcpServers") } // Verify linear server configuration if linear["disabled"] != false { t.Errorf("Expected linear server to be enabled") } args, ok := linear["args"].([]interface{}) if !ok || len(args) != 2 || args[0] != "serve" || args[1] != "--write-access=true" { t.Errorf("Expected linear server args to be [\"serve\", \"--write-access=true\"], got %v", args) } } ```