This is page 5 of 6. Use http://codebase.md/geropl/linear-mcp-go?lines=true&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 -------------------------------------------------------------------------------- /docs/design/002-project-milestone-initiative.md: -------------------------------------------------------------------------------- ```markdown 1 | # Design Doc: Project, Milestone, and Initiative Support 2 | 3 | ## 1. Overview 4 | 5 | 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. 6 | 7 | ## 2. Guiding Principles 8 | 9 | * **Consistency**: The new tools and code will follow the existing architecture and design patterns of the server. 10 | * **Modularity**: Each entity will be implemented in a modular way, with clear separation between models, client methods, and tool handlers. 11 | * **User-Friendliness**: Tools will accept user-friendly identifiers (names, slugs) in addition to UUIDs, similar to the existing `issue` and `team` parameters. 12 | * **Testability**: All new functionality will be covered by unit tests using the existing `go-vcr` framework. 13 | 14 | ## 3. Architecture Changes 15 | 16 | The existing architecture is well-suited for extension. The primary changes will be: 17 | 18 | * **`pkg/linear/models.go`**: Add new structs for `Project`, `ProjectMilestone`, `Initiative`, and their related types (e.g., `ProjectConnection`, `ProjectCreateInput`). 19 | * **`pkg/linear/client.go`**: Add new methods to the `LinearClient` for interacting with the new entities. 20 | * **`pkg/tools/`**: Create new files for each entity's tools (e.g., `project_tools.go`, `milestone_tools.go`, `initiative_tools.go`). 21 | * **`pkg/server/server.go`**: Update `RegisterTools` to include the new tools. 22 | 23 | No fundamental changes to the core server logic or command structure are anticipated. 24 | 25 | ## 4. Implementation Plan 26 | 27 | This section details the sub-tasks for implementing each handler. 28 | 29 | ### 4.1. Project Entity 30 | 31 | #### 4.1.1. `linear_get_project` Handler 32 | 33 | - [x] **Model**: Define `Project` and `ProjectConnection` structs in `pkg/linear/models.go`. 34 | - [x] **Client**: Implement `GetProject(identifier string)` in `pkg/linear/client.go`. 35 | - [x] Add a resolver function to handle UUIDs, names, and slug IDs. 36 | - [x] Implement the GraphQL query to fetch a single project. 37 | - [x] **Tool**: Create `GetProjectTool` in `pkg/tools/project_tools.go`. 38 | - [x] Define the tool with the name `linear_get_project`. 39 | - [x] Add a description: "Get a single project by its identifier (ID, name, or slug)." 40 | - [x] Define a required `project` string parameter. 41 | - [x] **Handler**: Implement `GetProjectHandler` in `pkg/tools/project_tools.go`. 42 | - [x] Extract the `project` identifier from the request. 43 | - [x] Call `linearClient.GetProject()` with the identifier. 44 | - [x] Format the returned `Project` object into a user-friendly string. 45 | - [x] Handle errors for not found projects. 46 | - [x] **Server**: Register the tool in `pkg/server/server.go`. 47 | - [x] **Test**: Add test cases to `pkg/server/tools_test.go`. 48 | 49 | #### 4.1.2. `linear_search_projects` Handler 50 | 51 | - [x] **Client**: Implement `SearchProjects(query string)` in `pkg/linear/client.go`. 52 | - [x] Implement the GraphQL query for searching projects. 53 | - [x] **Tool**: Create `SearchProjectsTool` in `pkg/tools/project_tools.go`. 54 | - [x] Define the tool with the name `linear_search_projects`. 55 | - [x] Add a description: "Search for projects." 56 | - [x] Define an optional `query` string parameter. 57 | - [x] **Handler**: Implement `SearchProjectsHandler` in `pkg/tools/project_tools.go`. 58 | - [x] Extract the `query` from the request. 59 | - [x] Call `linearClient.SearchProjects()`. 60 | - [x] Format the list of `Project` objects. 61 | - [x] **Server**: Register the tool in `pkg/server/server.go`. 62 | - [x] **Test**: Add test cases to `pkg/server/tools_test.go`. 63 | 64 | #### 4.1.3. `linear_create_project` Handler 65 | 66 | - [x] **Model**: Define `ProjectCreateInput` struct in `pkg/linear/models.go`. 67 | - [x] **Client**: Implement `CreateProject(input ProjectCreateInput)` in `pkg/linear/client.go`. 68 | - [x] Implement the GraphQL mutation to create a project. 69 | - [x] **Tool**: Create `CreateProjectTool` in `pkg/tools/project_tools.go`. 70 | - [x] Define the tool with the name `linear_create_project`. 71 | - [x] Add a description: "Create a new project." 72 | - [x] Define required parameters: `name`, `teamIds`. 73 | - [x] Define optional parameters: `description`, `leadId`, `startDate`, `targetDate`, etc. 74 | - [x] **Handler**: Implement `CreateProjectHandler` in `pkg/tools/project_tools.go`. 75 | - [x] Build the `ProjectCreateInput` from the request parameters. 76 | - [x] Call `linearClient.CreateProject()`. 77 | - [x] Format the newly created `Project` object. 78 | - [x] **Server**: Register the tool in `pkg/server/server.go` (respecting `writeAccess`). 79 | - [x] **Test**: Add test cases to `pkg/server/tools_test.go`. 80 | 81 | #### 4.1.4. `linear_update_project` Handler 82 | 83 | - [x] **Model**: Define `ProjectUpdateInput` struct in `pkg/linear/models.go`. 84 | - [x] **Client**: Implement `UpdateProject(id string, input ProjectUpdateInput)` in `pkg/linear/client.go`. 85 | - [x] Implement the GraphQL mutation to update a project. 86 | - [x] **Tool**: Create `UpdateProjectTool` in `pkg/tools/project_tools.go`. 87 | - [x] Define the tool with the name `linear_update_project`. 88 | - [x] Add a description: "Update an existing project." 89 | - [x] Define a required `project` parameter. 90 | - [x] Define optional parameters for updatable fields (`name`, `description`, etc.). 91 | - [x] **Handler**: Implement `UpdateProjectHandler` in `pkg/tools/project_tools.go`. 92 | - [x] Resolve the `project` identifier to a UUID. 93 | - [x] Build the `ProjectUpdateInput`. 94 | - [x] Call `linearClient.UpdateProject()`. 95 | - [x] Format the updated `Project` object. 96 | - [x] **Server**: Register the tool in `pkg/server/server.go` (respecting `writeAccess`). 97 | - [x] **Test**: Add test cases to `pkg/server/tools_test.go`. 98 | 99 | ### 4.2. ProjectMilestone Entity 100 | 101 | #### 4.2.1. `linear_get_milestone` Handler 102 | 103 | - [x] **Model**: Define `ProjectMilestone` and `ProjectMilestoneConnection` structs in `pkg/linear/models.go`. 104 | - [x] **Client**: Implement `GetMilestone(id string)` in `pkg/linear/client.go`. 105 | - [x] Implement the GraphQL query to fetch a single milestone. 106 | - [x] **Tool**: Create `GetMilestoneTool` in `pkg/tools/milestone_tools.go`. 107 | - [x] Define the tool with the name `linear_get_milestone`. 108 | - [x] Add a description: "Get a single project milestone by its ID." 109 | - [x] Define a required `milestoneId` string parameter. 110 | - [x] **Handler**: Implement `GetMilestoneHandler` in `pkg/tools/milestone_tools.go`. 111 | - [x] Call `linearClient.GetMilestone()`. 112 | - [x] Format the returned `ProjectMilestone` object. 113 | - [x] **Server**: Register the tool in `pkg/server/server.go`. 114 | - [x] **Test**: Add test cases to `pkg/server/tools_test.go`. 115 | 116 | #### 4.2.2. `linear_create_milestone` Handler 117 | 118 | - [x] **Model**: Define `ProjectMilestoneCreateInput` in `pkg/linear/models.go`. 119 | - [x] **Client**: Implement `CreateMilestone(input ProjectMilestoneCreateInput)` in `pkg/linear/client.go`. 120 | - [x] Implement the GraphQL mutation. 121 | - [x] **Tool**: Create `CreateMilestoneTool` in `pkg/tools/milestone_tools.go`. 122 | - [x] Define the tool with the name `linear_create_milestone`. 123 | - [x] Add a description: "Create a new project milestone." 124 | - [x] Define required parameters: `name`, `projectId`. 125 | - [x] Define optional parameters: `description`, `targetDate`. 126 | - [x] **Handler**: Implement `CreateMilestoneHandler` in `pkg/tools/milestone_tools.go`. 127 | - [x] Resolve `projectId`. 128 | - [x] Build and call `linearClient.CreateMilestone()`. 129 | - [x] Format the new `ProjectMilestone`. 130 | - [x] **Server**: Register the tool in `pkg/server/server.go` (respecting `writeAccess`). 131 | - [x] **Test**: Add test cases to `pkg/server/tools_test.go`. 132 | 133 | ### 4.3. Initiative Entity 134 | 135 | #### 4.3.1. `linear_get_initiative` Handler 136 | 137 | - [x] **Model**: Define `Initiative` and `InitiativeConnection` structs in `pkg/linear/models.go`. 138 | - [x] **Client**: Implement `GetInitiative(identifier string)` in `pkg/linear/client.go`. 139 | - [x] Add a resolver for UUID and name. 140 | - [x] Implement the GraphQL query. 141 | - [x] **Tool**: Create `GetInitiativeTool` in `pkg/tools/initiative_tools.go`. 142 | - [x] Define the tool with the name `linear_get_initiative`. 143 | - [x] Add a description: "Get a single initiative by its identifier (ID or name)." 144 | - [x] Define a required `initiative` string parameter. 145 | - [x] **Handler**: Implement `GetInitiativeHandler` in `pkg/tools/initiative_tools.go`. 146 | - [x] Call `linearClient.GetInitiative()`. 147 | - [x] Format the returned `Initiative` object. 148 | - [x] **Server**: Register the tool in `pkg/server/server.go`. 149 | - [x] **Test**: Add test cases to `pkg/server/tools_test.go`. 150 | 151 | #### 4.3.2. `linear_create_initiative` Handler 152 | 153 | - [x] **Model**: Define `InitiativeCreateInput` in `pkg/linear/models.go`. 154 | - [x] **Client**: Implement `CreateInitiative(input InitiativeCreateInput)` in `pkg/linear/client.go`. 155 | - [x] Implement the GraphQL mutation. 156 | - [x] **Tool**: Create `CreateInitiativeTool` in `pkg/tools/initiative_tools.go`. 157 | - [x] Define the tool with the name `linear_create_initiative`. 158 | - [x] Add a description: "Create a new initiative." 159 | - [x] Define a required `name` parameter. 160 | - [x] Define optional parameters like `description`. 161 | - [x] **Handler**: Implement `CreateInitiativeHandler` in `pkg/tools/initiative_tools.go`. 162 | - [x] Build and call `linearClient.CreateInitiative()`. 163 | - [x] Format the new `Initiative`. 164 | - [x] **Server**: Register the tool in `pkg/server/server.go` (respecting `writeAccess`). 165 | - [x] **Test**: Add test cases to `pkg/server/tools_test.go`. 166 | 167 | ### 4.4.1 Re-record tests 168 | 169 | - [x] Coordinate with user to prepare test data and re-record requests 170 | - [x] Iterate over each new test cases and validate that it works 171 | 172 | ### 4.4.2 Issues detected during testing 173 | 174 | - [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 175 | - [x] search_projects: Cannot find multiple projects (prefix search only?) 176 | - [x] issues: 177 | - [x] display milestone and project association 178 | - [x] allow to update milestone and project association 179 | - [x] projects: 180 | - [x] display initiative association 181 | - [x] allow to update initiative association 182 | - [x] create_milestone: date parsing issue 183 | 184 | ## 5. Testing Strategy 185 | 186 | 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. 187 | 188 | * **Unit Tests**: Tests are implemented in `pkg/server/tools_test.go`. 189 | * **Fixtures**: Use `go-vcr` to record real API interactions for each new client method. Fixtures will be stored in `testdata/fixtures/`. 190 | * **Golden Files**: Expected outputs for each test case will be stored in `testdata/golden/`. 191 | * **Coverage**: Ensure that both success and error paths are tested for each handler. This includes testing with invalid identifiers, missing parameters, and API errors. 192 | 193 | ## 6. Future Considerations 194 | 195 | * **Full CRUD**: This plan covers the most common operations. Full CRUD (including delete) can be added later if needed. 196 | * **Relationships**: Add tools for managing relationships between these entities (e.g., adding a project to an initiative). 197 | * **Resources**: Expose these new entities as MCP resources for easy reference in other tools. 198 | 199 | ## 7. Critique and Improvements 200 | 201 | Based on a review of the initial implementation, several areas for improvement have been identified to enhance tool symmetry and consistency. 202 | 203 | ### 7.1. Enhance `linear_update_project` 204 | 205 | - [x] **Symmetry**: The `linear_update_project` tool should support the same set of optional parameters as `linear_create_project`. 206 | - [x] **Task**: Add `leadId`, `startDate`, `targetDate`, and `teamIds` as optional parameters to the `UpdateProjectTool` definition in `pkg/tools/project_tools.go`. 207 | - [x] **Task**: Update the `UpdateProjectHandler` to handle these new parameters. 208 | - [x] **Task**: Update the `linearClient.UpdateProject` method and `ProjectUpdateInput` struct to include these fields. 209 | - [ ] **Test**: Add test cases to verify that each field can be updated individually and in combination. 210 | 211 | ### 7.2. Add `linear_update_milestone` 212 | 213 | - [x] **Symmetry**: Add a `linear_update_milestone` tool to provide full CRUD operations for milestones. 214 | - [x] **Model**: Define `ProjectMilestoneUpdateInput` struct in `pkg/linear/models.go`. 215 | - [x] **Client**: Implement `UpdateMilestone(id string, input ProjectMilestoneUpdateInput)` in `pkg/linear/client.go`. 216 | - [x] **Tool**: Create `UpdateMilestoneTool` in `pkg/tools/milestone_tools.go` with a required `milestone` parameter and optional `name`, `description`, and `targetDate` parameters. 217 | - [x] **Handler**: Implement `UpdateMilestoneHandler` in `pkg/tools/milestone_tools.go`. 218 | - [x] **Server**: Register the new tool in `pkg/server/server.go`. 219 | - [ ] **Test**: Add test cases for the new tool. 220 | 221 | ### 7.3. Add `linear_update_initiative` 222 | 223 | - [x] **Symmetry**: Add a `linear_update_initiative` tool to provide full CRUD operations for initiatives. 224 | - [x] **Model**: Define `InitiativeUpdateInput` struct in `pkg/linear/models.go`. 225 | - [x] **Client**: Implement `UpdateInitiative(id string, input InitiativeUpdateInput)` in `pkg/linear/client.go`. 226 | - [x] **Tool**: Create `UpdateInitiativeTool` in `pkg/tools/initiative_tools.go` with a required `initiative` parameter and optional `name` and `description` parameters. 227 | - [x] **Handler**: Implement `UpdateInitiativeHandler` in `pkg/tools/initiative_tools.go`. 228 | - [x] **Server**: Register the new tool in `pkg/server/server.go`. 229 | - [ ] **Test**: Add test cases for the new tool. 230 | 231 | ### 7.4. Standardize Identifier Parameters 232 | 233 | - [x] **Consistency**: Ensure all `get` and `update` tools use a consistent and user-friendly identifier parameter. 234 | - [x] **Task**: In `pkg/tools/milestone_tools.go`, rename the `milestoneId` parameter of `GetMilestoneTool` to `milestone`. 235 | - [x] **Task**: Update the `GetMilestoneHandler` to use the `milestone` parameter. 236 | - [x] **Task**: Enhance `linearClient.GetMilestone` to resolve the milestone by name in addition to ID, similar to how `GetProject` works. 237 | - [ ] **Test**: Update existing tests for `linear_get_milestone` and add new tests for name-based resolution. 238 | ``` -------------------------------------------------------------------------------- /memory-bank/activeContext.md: -------------------------------------------------------------------------------- ```markdown 1 | # Active Context: Linear MCP Server 2 | 3 | ## Current Work Focus 4 | The current focus is on enhancing the functionality and user experience of the Linear MCP Server. This includes: 5 | 1. Improving the user experience by adding a setup command that simplifies the installation and configuration process 6 | 2. Enhancing the Linear API integration with support for more advanced features 7 | 3. Supporting multiple AI assistants (starting with Cline) 8 | 4. Ensuring cross-platform compatibility 9 | 5. Expanding the capabilities of existing MCP tools 10 | 11 | ## Recent Changes 12 | 1. Completed Tool Standardization Testing: 13 | - Updated test fixtures to reflect the new standardized format 14 | - Updated test cases to use the new parameter names (e.g., `issue` instead of `issueId`) 15 | - Verified that all tests pass with the new implementation 16 | - Updated tracking document to mark Phase 3 (Update Tests) as completed 17 | - Updated progress.md to reflect the completion of Tool Standardization testing 18 | 19 | 2. Implemented Tool Standardization: 20 | - Created shared utility functions for entity rendering and identifier resolution 21 | - Updated all tools to follow standardization rules: 22 | - Concise descriptions that focus only on functionality 23 | - Flexible identifier resolution for all entity references 24 | - Consistent entity rendering with both full and identifier formats 25 | - Consistent parameter naming that reflects the entity type (e.g., `issue` instead of `issueId`) 26 | - Created comprehensive documentation in a series of PRD files (000, 002, 003, 004, 005) 27 | - Updated tracking document to reflect implementation progress 28 | 29 | 2. Implemented CLI framework with subcommands: 30 | - Added the Cobra library for command-line handling 31 | - Restructured the main.go file to support subcommands 32 | - Created a root command that serves as the base for all subcommands 33 | - Moved the existing server functionality to a server subcommand 34 | 35 | 2. Created a setup command: 36 | - Implemented a setup command that automates the installation and configuration process 37 | - Added support for the Cline AI assistant 38 | - Implemented binary discovery and download functionality 39 | - Added configuration file management for AI assistants 40 | 41 | 3. Updated documentation: 42 | - Updated README.md with information about the new setup command 43 | - Added examples of how to use the setup command 44 | - Clarified the usage of the server command 45 | 46 | 4. Enhanced `linear_create_issue` tool: 47 | - Added support for creating sub-issues by specifying a parent issue ID or identifier (e.g., "TEAM-123") 48 | - Added support for assigning labels during issue creation using label IDs or names 49 | - Implemented resolution functions for parent issue identifiers and label names 50 | - Updated the Linear client to handle these new parameters 51 | - Added test cases and fixtures for the new functionality 52 | - Updated documentation to reflect the new capabilities 53 | 54 | 5. Fixed JSON unmarshaling issue with Labels field: 55 | - Updated the `Issue` struct in `models.go` to change the `Labels` field from `[]Label` to `*LabelConnection` 56 | - Added a new `LabelConnection` struct to match the structure returned by the Linear API 57 | - Updated test fixtures and golden files to reflect the changes 58 | - Added a new test case for creating issues with team key 59 | 60 | 6. Fixed label resolution issue: 61 | - Updated the GraphQL query in `GetLabelsByName` function to change the `$teamId` parameter type from `ID!` to `String!` 62 | - Re-recorded test fixtures for label-related tests 63 | - Updated golden files to reflect the new error messages 64 | - All tests now pass successfully 65 | 66 | 7. Fixed parent issue identifier resolution: 67 | - Updated the `GetIssueByIdentifier` function to split the identifier (e.g., "TEAM-123") into team key and number parts 68 | - Modified the GraphQL query to use the team key and number in the filter instead of the full identifier 69 | - Added proper error handling for invalid identifier formats 70 | - Added a new test case for creating sub-issues using human-readable identifiers 71 | - All tests now pass successfully 72 | 73 | 8. Enhanced Claude Code Support in Setup Command: 74 | - **Feature 1**: Register to all existing projects when no --project-path is specified 75 | - Modified `setupClaudeCode` function to read existing projects from `.claude.json` 76 | - Added `getAllExistingProjects` helper function to extract project paths 77 | - Implemented logic to register Linear MCP server to all existing projects automatically 78 | - Added proper error handling when no existing projects are found 79 | - **Feature 2**: Support multiple project paths separated by commas 80 | - Added comma-separated project path parsing with whitespace trimming 81 | - Modified registration logic to handle multiple target projects 82 | - Added validation for empty project path lists 83 | - **Implementation Details**: 84 | - Created `registerLinearToProject` helper function for reusable project registration logic 85 | - Preserved all existing configuration settings and project structures 86 | - Added comprehensive logging to show which projects are being registered 87 | - Updated flag help text to document the new behavior 88 | - **Testing**: 89 | - Added 5 new comprehensive test cases covering all scenarios: 90 | - Register to all existing projects (empty project path) 91 | - Multiple comma-separated project paths with whitespace handling 92 | - Mixed existing and new projects 93 | - Error handling for empty project lists 94 | - Error handling when no existing projects found 95 | - All tests pass successfully 96 | - Manual testing confirmed functionality works as expected 97 | 98 | 9. **UPDATED**: Implemented User-Scoped MCP Server Registration for Claude Code: 99 | - **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 100 | - **Solution Implemented**: Use user-scoped `mcpServers` registration instead of project-scoped when no project path is specified 101 | - **Key Changes**: 102 | - **Updated `setupClaudeCode` function**: Now registers to user-scoped `mcpServers` (root level) when `projectPath` is empty 103 | - **Added `registerLinearToUserScope` function**: Handles registration to the root-level `mcpServers` object that applies to all projects 104 | - **Removed obsolete `getAllExistingProjects` function**: No longer needed since we use user-scoped registration 105 | - **Updated flag help text**: Changed from "register to all existing projects" to "register to user scope for all projects" 106 | - **Benefits of User-Scoped Approach**: 107 | - **Simpler logic**: No need to iterate through existing projects 108 | - **Better user experience**: Works even when no projects exist yet 109 | - **Future-proof**: Automatically applies to new projects created later 110 | - **Matches Claude Code design**: Uses the intended global registration mechanism 111 | - **JSON Structure Changes**: 112 | - **When `projectPath` is empty**: Registers to root-level `mcpServers` (user-scoped) 113 | - **When `projectPath` is specified**: Continues using project-scoped registration in `projects[path].mcpServers` 114 | - **Updated Test Cases**: 115 | - **Modified existing test**: "Claude Code Register to All Existing Projects" → "Claude Code Register to User Scope with Existing Projects" 116 | - **Updated error test**: "Claude Code No Existing Projects and No Project Path" now expects success with user-scoped registration 117 | - **Added new test cases**: 118 | - User-scoped registration with existing user-scoped servers 119 | - User-scoped update of existing Linear server 120 | - Comprehensive coverage of both user-scoped and project-scoped scenarios 121 | - **Implementation Status**: Complete and ready for testing 122 | 123 | ## Next Steps 124 | 1. **Testing the Setup Command**: 125 | - Test the setup command on different platforms (Linux, macOS, Windows) 126 | - Verify that the configuration files are correctly created 127 | - Ensure that the binary download works correctly 128 | 129 | 2. **Adding Support for More AI Assistants**: 130 | - Research other AI assistants that could benefit from Linear integration 131 | - Implement support for these assistants in the setup command 132 | - Update documentation with information about the new assistants 133 | 134 | 3. **Future Enhancements**: 135 | - Add more Linear API features as MCP tools 136 | - Improve error handling and reporting 137 | - Add configuration file support for the server 138 | 139 | ## Active Decisions and Considerations 140 | 141 | ### Tool Standardization Approach 142 | - **Decision**: Implement standardization in phases, focusing on shared utility functions first 143 | - **Rationale**: Creating shared functions first ensures consistency across all tools 144 | - **Alternatives Considered**: Updating each tool individually, creating a new set of tools 145 | - **Implications**: More maintainable codebase with consistent patterns 146 | 147 | ### Tool Description Style 148 | - **Decision**: Make tool descriptions concise and focused on functionality 149 | - **Rationale**: Concise descriptions are easier to read and understand 150 | - **Alternatives Considered**: Keeping verbose descriptions, creating separate documentation 151 | - **Implications**: Improved user experience with clearer tool descriptions 152 | 153 | ### Parameter Naming Convention 154 | - **Decision**: Use entity names for parameters that accept identifiers (e.g., `issue` instead of `issueId`) 155 | - **Rationale**: Parameter names should reflect what they represent rather than implementation details 156 | - **Alternatives Considered**: Keeping technical names like `issueId`, using different naming patterns 157 | - **Implications**: More intuitive API that aligns with the flexible identifier resolution approach 158 | 159 | ### Identifier Resolution Strategy 160 | - **Decision**: Extend existing resolution functions and create new ones as needed 161 | - **Rationale**: Builds on existing functionality while ensuring consistency 162 | - **Alternatives Considered**: Creating entirely new resolution system, handling resolution in each tool 163 | - **Implications**: More flexible parameter handling with consistent behavior 164 | 165 | ### Entity Rendering Approach 166 | - **Decision**: Create two types of formatting functions for each entity type 167 | - **Rationale**: Distinguishes between full entity rendering and entity identifier rendering 168 | - **Alternatives Considered**: Single formatting function, custom formatting in each tool, using templates 169 | - **Implications**: Consistent user experience with standardized output format that is appropriate for the context 170 | 171 | ### Full Entity vs. Identifier Rendering 172 | - **Decision**: Use full entity rendering for primary entities and identifier rendering for referenced entities 173 | - **Rationale**: Provides comprehensive information for primary entities while keeping references concise 174 | - **Alternatives Considered**: Using only full rendering or only identifier rendering for all cases 175 | - **Implications**: Better readability and more intuitive output format 176 | 177 | ### CLI Framework Selection 178 | - **Decision**: Use the Cobra library for command-line handling 179 | - **Rationale**: Cobra is a widely used library for Go CLI applications with good documentation and community support 180 | - **Alternatives Considered**: urfave/cli, flag package 181 | - **Implications**: Provides a consistent way to handle subcommands and flags 182 | 183 | ### Setup Command Design 184 | - **Decision**: Implement a setup command that automates the installation and configuration process 185 | - **Rationale**: Simplifies the user experience by automating manual steps 186 | - **Alternatives Considered**: Keeping the bash script, creating a separate tool 187 | - **Implications**: Users can easily set up the server for use with AI assistants 188 | 189 | ### AI Assistant Support 190 | - **Decision**: Start with Cline support and design for extensibility 191 | - **Rationale**: Cline is the primary target, but the design should allow for adding more assistants 192 | - **Alternatives Considered**: Supporting only Cline, supporting multiple assistants from the start 193 | - **Implications**: The code is structured to easily add support for more assistants in the future 194 | 195 | ### Binary Management 196 | - **Decision**: Check for existing binary before downloading 197 | - **Rationale**: Avoids unnecessary downloads if the binary is already installed 198 | - **Alternatives Considered**: Always downloading the latest version 199 | - **Implications**: Faster setup process for users who already have the binary 200 | 201 | ### Configuration File Management 202 | - **Decision**: Merge new settings with existing settings 203 | - **Rationale**: Preserves user's existing configuration while adding the Linear MCP server 204 | - **Alternatives Considered**: Overwriting the entire file 205 | - **Implications**: Users can have multiple MCP servers configured 206 | 207 | ### Linear Issue Creation Enhancement 208 | - **Decision**: Enhance the `linear_create_issue` tool with support for user-friendly identifiers 209 | - **Rationale**: Provides more flexibility and better user experience when creating issues 210 | - **Alternatives Considered**: Requiring UUIDs only, creating separate tools for different identifier types 211 | - **Implications**: Users can create issues with more intuitive identifiers without needing to look up UUIDs 212 | 213 | ### Identifier Resolution Implementation 214 | - **Decision**: Implement separate resolution functions for parent issues and labels 215 | - **Rationale**: Keeps the code modular and easier to maintain 216 | - **Alternatives Considered**: Implementing a generic resolution function, handling resolution in the handler directly 217 | - **Implications**: Code is more maintainable and easier to extend for future enhancements 218 | 219 | ### JSON Structure Handling 220 | - **Decision**: Update the `Issue` struct to match the nested structure returned by the Linear API 221 | - **Rationale**: Ensures proper JSON unmarshaling of API responses 222 | - **Alternatives Considered**: Custom unmarshaling logic, flattening the structure in the client 223 | - **Implications**: More robust handling of API responses and fewer unmarshaling errors 224 | 225 | ### GraphQL Parameter Type Correction 226 | - **Decision**: Update the GraphQL query parameter types to match the Linear API expectations 227 | - **Rationale**: Ensures proper validation of GraphQL queries by the Linear API 228 | - **Alternatives Considered**: Custom error handling for API validation errors 229 | - **Implications**: More reliable API requests and fewer validation errors 230 | 231 | ### Parent Issue Identifier Resolution 232 | - **Decision**: Split the identifier into team key and number parts for the GraphQL query 233 | - **Rationale**: The Linear API doesn't support searching for issues by the full identifier directly 234 | - **Alternatives Considered**: Using a different API endpoint, implementing a custom search function 235 | - **Implications**: More reliable resolution of human-readable identifiers to UUIDs 236 | 237 | ## Open Questions 238 | 1. Should we add support for more AI assistants in the setup command? 239 | 2. Do we need to add any additional validation steps for the API key? 240 | 3. Should we implement automatic updates for the binary? 241 | 4. How can we improve the error handling for network and file system operations? 242 | ``` -------------------------------------------------------------------------------- /cmd/setup.go: -------------------------------------------------------------------------------- ```go 1 | package cmd 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | "os" 8 | "os/exec" 9 | "path/filepath" 10 | "runtime" 11 | "strings" 12 | 13 | "github.com/geropl/linear-mcp-go/pkg/server" 14 | "github.com/spf13/cobra" 15 | ) 16 | 17 | // setupCmd represents the setup command 18 | var setupCmd = &cobra.Command{ 19 | Use: "setup", 20 | Short: "Set up the Linear MCP server for use with an AI assistant", 21 | Long: `Set up the Linear MCP server for use with an AI assistant. 22 | This command installs the Linear MCP server and configures it for use with the specified AI assistant tool(s). 23 | Currently supported tools: cline, roo-code, claude-code, ona`, 24 | Run: func(cmd *cobra.Command, args []string) { 25 | toolParam, _ := cmd.Flags().GetString("tool") 26 | writeAccess, _ := cmd.Flags().GetBool("write-access") 27 | writeAccessChanged := cmd.Flags().Changed("write-access") 28 | autoApprove, _ := cmd.Flags().GetString("auto-approve") 29 | projectPath, _ := cmd.Flags().GetString("project-path") 30 | 31 | // Check if the Linear API key is provided in the environment (for tools that need it) 32 | apiKey := os.Getenv("LINEAR_API_KEY") 33 | tools := strings.Split(toolParam, ",") 34 | for _, t := range tools { 35 | doesNotNeedApiKey := strings.TrimSpace(t) == "ona" 36 | if doesNotNeedApiKey { 37 | continue 38 | } 39 | 40 | if apiKey == "" { 41 | fmt.Println("Error: LINEAR_API_KEY environment variable is required") 42 | fmt.Println("Please set it before running the setup command:") 43 | fmt.Println("export LINEAR_API_KEY=your_linear_api_key") 44 | os.Exit(1) 45 | } 46 | } 47 | 48 | // Create the MCP servers directory if it doesn't exist 49 | homeDir, err := os.UserHomeDir() 50 | if err != nil { 51 | fmt.Printf("Error getting user home directory: %v\n", err) 52 | os.Exit(1) 53 | } 54 | 55 | mcpServersDir := filepath.Join(homeDir, "mcp-servers") 56 | if err := os.MkdirAll(mcpServersDir, 0755); err != nil { 57 | fmt.Printf("Error creating MCP servers directory: %v\n", err) 58 | os.Exit(1) 59 | } 60 | 61 | // Check if the Linear MCP binary is already on the path 62 | binaryPath, found := checkBinary() 63 | if !found { 64 | fmt.Printf("Linear MCP binary not found on path, copying current binary to '%s'...\n", binaryPath) 65 | err := copySelfToBinaryPath(binaryPath) 66 | if err != nil { 67 | fmt.Printf("Error copying Linear MCP binary: %v\n", err) 68 | os.Exit(1) 69 | } 70 | } 71 | 72 | // Process each tool 73 | hasErrors := false 74 | for _, t := range tools { 75 | t = strings.TrimSpace(t) 76 | if t == "" { 77 | continue 78 | } 79 | 80 | fmt.Printf("Setting up tool: %s\n", t) 81 | 82 | // Set up the tool-specific configuration 83 | var err error 84 | switch strings.ToLower(t) { 85 | case "cline": 86 | err = setupCline(binaryPath, apiKey, writeAccess, autoApprove) 87 | case "roo-code": 88 | err = setupRooCode(binaryPath, apiKey, writeAccess, autoApprove) 89 | case "claude-code": 90 | err = setupClaudeCode(binaryPath, apiKey, writeAccess, autoApprove, projectPath) 91 | case "ona": 92 | err = setupOna(binaryPath, apiKey, writeAccess, writeAccessChanged, autoApprove, projectPath) 93 | default: 94 | fmt.Printf("Unsupported tool: %s\n", t) 95 | fmt.Println("Currently supported tools: cline, roo-code, claude-code, ona") 96 | hasErrors = true 97 | continue 98 | } 99 | 100 | if err != nil { 101 | fmt.Printf("Error setting up %s: %v\n", t, err) 102 | hasErrors = true 103 | } else { 104 | fmt.Printf("Linear MCP server successfully set up for %s\n", t) 105 | } 106 | } 107 | 108 | if hasErrors { 109 | os.Exit(1) 110 | } 111 | }, 112 | } 113 | 114 | func init() { 115 | rootCmd.AddCommand(setupCmd) 116 | 117 | // Add flags to the setup command 118 | setupCmd.Flags().String("tool", "cline", "The AI assistant tool(s) to set up for (comma-separated, e.g., cline,roo-code,claude-code,ona)") 119 | setupCmd.Flags().Bool("write-access", false, "Enable write operations (default: false)") 120 | 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") 121 | 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)") 122 | } 123 | 124 | // checkBinary checks if the Linear MCP binary is already on the path 125 | func checkBinary() (string, bool) { 126 | // Try to find the binary on the path 127 | path, err := exec.LookPath("linear-mcp-go") 128 | if err == nil { 129 | fmt.Printf("Found Linear MCP binary at %s\n", path) 130 | return path, true 131 | } 132 | 133 | // Check if the binary exists in the home directory 134 | homeDir, err := os.UserHomeDir() 135 | if err != nil { 136 | return "", false 137 | } 138 | 139 | binaryPath := filepath.Join(homeDir, "mcp-servers", "linear-mcp-go") 140 | if runtime.GOOS == "windows" { 141 | binaryPath += ".exe" 142 | } 143 | 144 | if _, err := os.Stat(binaryPath); err == nil { 145 | fmt.Printf("Found Linear MCP binary at %s\n", binaryPath) 146 | return binaryPath, true 147 | } 148 | 149 | return binaryPath, false 150 | } 151 | 152 | // copySelfToBinaryPath copies the current executable to the specified path 153 | func copySelfToBinaryPath(binaryPath string) error { 154 | // Get the path to the current executable 155 | execPath, err := os.Executable() 156 | if err != nil { 157 | return fmt.Errorf("failed to get executable path: %w", err) 158 | } 159 | 160 | // Check if the destination is the same as the source 161 | absExecPath, _ := filepath.Abs(execPath) 162 | absDestPath, _ := filepath.Abs(binaryPath) 163 | if absExecPath == absDestPath { 164 | return nil // Already in the right place 165 | } 166 | 167 | // Copy the file 168 | sourceFile, err := os.Open(execPath) 169 | if err != nil { 170 | return fmt.Errorf("failed to open source file: %w", err) 171 | } 172 | defer sourceFile.Close() 173 | 174 | err = os.MkdirAll(filepath.Dir(binaryPath), 0755) 175 | if err != nil { 176 | return fmt.Errorf("failed to create destination directory: %w", err) 177 | } 178 | 179 | destFile, err := os.Create(binaryPath) 180 | if err != nil { 181 | return fmt.Errorf("failed to create destination file: %w", err) 182 | } 183 | defer destFile.Close() 184 | 185 | if _, err := io.Copy(destFile, sourceFile); err != nil { 186 | return fmt.Errorf("failed to copy file: %w", err) 187 | } 188 | 189 | // Make the binary executable 190 | if runtime.GOOS != "windows" { 191 | if err := os.Chmod(binaryPath, 0755); err != nil { 192 | return fmt.Errorf("failed to make binary executable: %w", err) 193 | } 194 | } 195 | 196 | fmt.Printf("Linear MCP server installed successfully at %s\n", binaryPath) 197 | return nil 198 | } 199 | 200 | // getOnaConfigPath determines the configuration file path for Ona 201 | func getOnaConfigPath(projectPath string) (string, error) { 202 | cwd, err := os.Getwd() 203 | if err != nil { 204 | return "", fmt.Errorf("failed to get current working directory: %w", err) 205 | } 206 | 207 | // Default to current working directory 208 | baseDir := cwd 209 | 210 | // If project path is specified, use the first one 211 | if projectPath != "" { 212 | paths := strings.Split(projectPath, ",") 213 | trimmedPath := strings.TrimSpace(paths[0]) 214 | if trimmedPath != "" { 215 | baseDir = trimmedPath 216 | // If absolute path doesn't exist, treat as relative to current directory 217 | if filepath.IsAbs(trimmedPath) { 218 | if _, err := os.Stat(trimmedPath); os.IsNotExist(err) { 219 | relativePath := strings.TrimPrefix(trimmedPath, "/") 220 | baseDir = filepath.Join(cwd, relativePath) 221 | } 222 | } 223 | } 224 | } 225 | 226 | return filepath.Join(baseDir, ".ona", "mcp-config.json"), nil 227 | } 228 | 229 | // extractTrailingWhitespace extracts trailing whitespace (newlines, spaces, tabs) from a string 230 | func extractTrailingWhitespace(content string) string { 231 | // Find the last non-whitespace character 232 | i := len(content) - 1 233 | for i >= 0 && (content[i] == ' ' || content[i] == '\t' || content[i] == '\n' || content[i] == '\r') { 234 | i-- 235 | } 236 | // Return everything after the last non-whitespace character 237 | if i < len(content)-1 { 238 | return content[i+1:] 239 | } 240 | return "" 241 | } 242 | 243 | // setupOna sets up the Linear MCP server for Ona 244 | func setupOna(binaryPath, apiKey string, writeAccess bool, writeAccessChanged bool, autoApprove, projectPath string) error { 245 | configPath, err := getOnaConfigPath(projectPath) 246 | if err != nil { 247 | return err 248 | } 249 | 250 | // Create the .ona directory if it doesn't exist 251 | if err := os.MkdirAll(filepath.Dir(configPath), 0755); err != nil { 252 | return fmt.Errorf("failed to create .ona directory: %w", err) 253 | } 254 | 255 | // Prepare server arguments 256 | serverArgs := []string{"serve"} 257 | // Only add write-access argument if it was explicitly set 258 | if writeAccessChanged { 259 | serverArgs = append(serverArgs, fmt.Sprintf("--write-access=%t", writeAccess)) 260 | } 261 | 262 | // Create the linear server configuration 263 | linearServerConfig := map[string]interface{}{ 264 | "command": binaryPath, 265 | "args": serverArgs, 266 | "disabled": false, 267 | } 268 | 269 | // Ona will automatically provide LINEAR_API_KEY from environment 270 | // No need to explicitly set it in the configuration 271 | 272 | // Read existing configuration or create new one 273 | var config map[string]interface{} 274 | var originalTrailingWhitespace string 275 | data, err := os.ReadFile(configPath) 276 | if err != nil { 277 | if !os.IsNotExist(err) { 278 | return fmt.Errorf("failed to read existing ona config: %w", err) 279 | } 280 | // Initialize with empty structure if file doesn't exist 281 | config = map[string]interface{}{ 282 | "mcpServers": map[string]interface{}{}, 283 | } 284 | } else { 285 | // Preserve trailing whitespace from original file 286 | originalContent := string(data) 287 | originalTrailingWhitespace = extractTrailingWhitespace(originalContent) 288 | 289 | if err := json.Unmarshal(data, &config); err != nil { 290 | return fmt.Errorf("failed to parse existing ona config: %w", err) 291 | } 292 | // Ensure mcpServers field exists 293 | if config["mcpServers"] == nil { 294 | config["mcpServers"] = map[string]interface{}{} 295 | } 296 | } 297 | 298 | // Get or create mcpServers map 299 | mcpServers, ok := config["mcpServers"].(map[string]interface{}) 300 | if !ok { 301 | mcpServers = map[string]interface{}{} 302 | config["mcpServers"] = mcpServers 303 | } 304 | 305 | // Add/update the linear server configuration 306 | mcpServers["linear"] = linearServerConfig 307 | 308 | // Write the updated configuration 309 | updatedData, err := json.MarshalIndent(config, "", " ") 310 | if err != nil { 311 | return fmt.Errorf("failed to marshal ona config: %w", err) 312 | } 313 | 314 | // Append the original trailing whitespace to preserve formatting 315 | finalData := append(updatedData, []byte(originalTrailingWhitespace)...) 316 | 317 | if err := os.WriteFile(configPath, finalData, 0644); err != nil { 318 | return fmt.Errorf("failed to write ona config: %w", err) 319 | } 320 | 321 | fmt.Printf("Ona MCP configuration updated at %s\n", configPath) 322 | return nil 323 | } 324 | 325 | // setupTool sets up the Linear MCP server for a specific tool 326 | func setupTool(toolName string, binaryPath, apiKey string, writeAccess bool, autoApprove string, configDir string) error { 327 | // Create the config directory if it doesn't exist 328 | if err := os.MkdirAll(configDir, 0755); err != nil { 329 | return fmt.Errorf("failed to create config directory: %w", err) 330 | } 331 | 332 | serverArgs := []string{"serve"} 333 | if writeAccess { 334 | serverArgs = append(serverArgs, "--write-access=true") 335 | } 336 | 337 | // Process auto-approve flag 338 | autoApproveTools := []string{} 339 | if autoApprove != "" { 340 | if autoApprove == "allow-read-only" { 341 | // Get the list of read-only tools 342 | for k := range server.GetReadOnlyToolNames() { 343 | autoApproveTools = append(autoApproveTools, k) 344 | } 345 | } else { 346 | // Split comma-separated list 347 | for _, tool := range strings.Split(autoApprove, ",") { 348 | trimmedTool := strings.TrimSpace(tool) 349 | if trimmedTool != "" { 350 | autoApproveTools = append(autoApproveTools, trimmedTool) 351 | } 352 | } 353 | } 354 | } 355 | 356 | // Create the MCP settings file 357 | settingsPath := filepath.Join(configDir, "cline_mcp_settings.json") 358 | newSettings := map[string]interface{}{ 359 | "mcpServers": map[string]interface{}{ 360 | "linear": map[string]interface{}{ 361 | "command": binaryPath, 362 | "args": serverArgs, 363 | "env": map[string]string{"LINEAR_API_KEY": apiKey}, 364 | "disabled": false, 365 | "autoApprove": autoApproveTools, 366 | }, 367 | }, 368 | } 369 | 370 | // Check if the settings file already exists 371 | var settings map[string]interface{} 372 | if _, err := os.Stat(settingsPath); err == nil { 373 | // Read the existing settings 374 | data, err := os.ReadFile(settingsPath) 375 | if err != nil { 376 | return fmt.Errorf("failed to read existing settings: %w", err) 377 | } 378 | 379 | // Parse the existing settings 380 | if err := json.Unmarshal(data, &settings); err != nil { 381 | return fmt.Errorf("failed to parse existing settings: %w", err) 382 | } 383 | 384 | // Merge the new settings with the existing settings 385 | if mcpServers, ok := settings["mcpServers"].(map[string]interface{}); ok { 386 | mcpServers["linear"] = newSettings["mcpServers"].(map[string]interface{})["linear"] 387 | } else { 388 | settings["mcpServers"] = newSettings["mcpServers"] 389 | } 390 | } else { 391 | // Use the new settings 392 | settings = newSettings 393 | } 394 | 395 | // Write the settings to the file 396 | data, err := json.MarshalIndent(settings, "", " ") 397 | if err != nil { 398 | return fmt.Errorf("failed to marshal settings: %w", err) 399 | } 400 | 401 | if err := os.WriteFile(settingsPath, data, 0644); err != nil { 402 | return fmt.Errorf("failed to write settings: %w", err) 403 | } 404 | 405 | fmt.Printf("%s MCP settings updated at %s\n", toolName, settingsPath) 406 | return nil 407 | } 408 | 409 | // setupCline sets up the Linear MCP server for Cline 410 | func setupCline(binaryPath, apiKey string, writeAccess bool, autoApprove string) error { 411 | // Determine the Cline config directory 412 | homeDir, err := os.UserHomeDir() 413 | if err != nil { 414 | return fmt.Errorf("failed to get user home directory: %w", err) 415 | } 416 | 417 | var configDir string 418 | switch runtime.GOOS { 419 | case "darwin": 420 | configDir = filepath.Join(homeDir, "Library", "Application Support", "Code", "User", "globalStorage", "saoudrizwan.claude-dev", "settings") 421 | case "linux": 422 | configDir = filepath.Join(homeDir, ".vscode-server", "data", "User", "globalStorage", "saoudrizwan.claude-dev", "settings") 423 | case "windows": 424 | configDir = filepath.Join(homeDir, "AppData", "Roaming", "Code", "User", "globalStorage", "saoudrizwan.claude-dev", "settings") 425 | default: 426 | return fmt.Errorf("unsupported OS: %s", runtime.GOOS) 427 | } 428 | 429 | return setupTool("Cline", binaryPath, apiKey, writeAccess, autoApprove, configDir) 430 | } 431 | 432 | // setupRooCode sets up the Linear MCP server for Roo Code 433 | func setupRooCode(binaryPath, apiKey string, writeAccess bool, autoApprove string) error { 434 | // Determine the Roo Code config directory 435 | homeDir, err := os.UserHomeDir() 436 | if err != nil { 437 | return fmt.Errorf("failed to get user home directory: %w", err) 438 | } 439 | 440 | var configDir string 441 | switch runtime.GOOS { 442 | case "darwin": 443 | configDir = filepath.Join(homeDir, "Library", "Application Support", "Code", "User", "globalStorage", "rooveterinaryinc.roo-cline", "settings") 444 | case "linux": 445 | configDir = filepath.Join(homeDir, ".vscode-server", "data", "User", "globalStorage", "rooveterinaryinc.roo-cline", "settings") 446 | case "windows": 447 | configDir = filepath.Join(homeDir, "AppData", "Roaming", "Code", "User", "globalStorage", "rooveterinaryinc.roo-cline", "settings") 448 | default: 449 | return fmt.Errorf("unsupported OS: %s", runtime.GOOS) 450 | } 451 | 452 | return setupTool("Roo Code", binaryPath, apiKey, writeAccess, autoApprove, configDir) 453 | } 454 | 455 | // setupClaudeCode sets up the Linear MCP server for Claude Code 456 | func setupClaudeCode(binaryPath, apiKey string, writeAccess bool, autoApprove, projectPath string) error { 457 | if runtime.GOOS != "linux" { 458 | return fmt.Errorf("claude-code is only supported on Linux") 459 | } 460 | 461 | homeDir, err := os.UserHomeDir() 462 | if err != nil { 463 | return fmt.Errorf("failed to get user home directory: %w", err) 464 | } 465 | 466 | configPath := filepath.Join(homeDir, ".claude.json") 467 | if err := os.MkdirAll(filepath.Dir(configPath), 0755); err != nil { 468 | return fmt.Errorf("failed to create directory '%s': %w", filepath.Dir(configPath), err) 469 | } 470 | 471 | // Use flexible map structure to preserve all existing settings 472 | var settings map[string]interface{} 473 | data, err := os.ReadFile(configPath) 474 | if err != nil { 475 | if !os.IsNotExist(err) { 476 | return fmt.Errorf("failed to read claude code settings: %w", err) 477 | } 478 | // Initialize with empty structure if file doesn't exist 479 | settings = map[string]interface{}{ 480 | "projects": map[string]interface{}{}, 481 | } 482 | } else { 483 | if err := json.Unmarshal(data, &settings); err != nil { 484 | return fmt.Errorf("failed to parse claude code settings: %w", err) 485 | } 486 | // Ensure projects field exists 487 | if settings["projects"] == nil { 488 | settings["projects"] = map[string]interface{}{} 489 | } 490 | } 491 | 492 | serverArgs := []string{"serve"} 493 | if writeAccess { 494 | serverArgs = append(serverArgs, "--write-access=true") 495 | } 496 | 497 | autoApproveTools := []string{} 498 | if autoApprove != "" { 499 | if autoApprove == "allow-read-only" { 500 | for k := range server.GetReadOnlyToolNames() { 501 | autoApproveTools = append(autoApproveTools, k) 502 | } 503 | } else { 504 | for _, tool := range strings.Split(autoApprove, ",") { 505 | trimmedTool := strings.TrimSpace(tool) 506 | if trimmedTool != "" { 507 | autoApproveTools = append(autoApproveTools, trimmedTool) 508 | } 509 | } 510 | } 511 | } 512 | 513 | linearServerConfig := map[string]interface{}{ 514 | "type": "stdio", 515 | "command": binaryPath, 516 | "args": serverArgs, 517 | "env": map[string]string{"LINEAR_API_KEY": apiKey}, 518 | "disabled": false, 519 | "autoApprove": autoApproveTools, 520 | } 521 | 522 | if projectPath == "" { 523 | // Register to user-scoped mcpServers (applies to all projects) 524 | if err := registerLinearToUserScope(settings, linearServerConfig); err != nil { 525 | return fmt.Errorf("failed to register Linear MCP server to user scope: %w", err) 526 | } 527 | fmt.Printf("Registered Linear MCP server to user scope (applies to all projects)\n") 528 | } else { 529 | // Parse comma-separated project paths and register to specific projects 530 | var targetProjects []string 531 | for _, path := range strings.Split(projectPath, ",") { 532 | trimmedPath := strings.TrimSpace(path) 533 | if trimmedPath != "" { 534 | targetProjects = append(targetProjects, trimmedPath) 535 | } 536 | } 537 | if len(targetProjects) == 0 { 538 | return fmt.Errorf("no valid project paths provided") 539 | } 540 | 541 | fmt.Printf("Registering Linear MCP server to %d specified projects\n", len(targetProjects)) 542 | for _, projPath := range targetProjects { 543 | if err := registerLinearToProject(settings, projPath, linearServerConfig); err != nil { 544 | return fmt.Errorf("failed to register Linear MCP server to project '%s': %w", projPath, err) 545 | } 546 | fmt.Printf(" - Registered to project: %s\n", projPath) 547 | } 548 | } 549 | 550 | updatedData, err := json.MarshalIndent(settings, "", " ") 551 | if err != nil { 552 | return fmt.Errorf("failed to marshal claude code settings: %w", err) 553 | } 554 | 555 | if err := os.WriteFile(configPath, updatedData, 0644); err != nil { 556 | return fmt.Errorf("failed to write claude code settings: %w", err) 557 | } 558 | 559 | fmt.Printf("Claude Code MCP settings updated at %s\n", configPath) 560 | return nil 561 | } 562 | 563 | // registerLinearToUserScope registers the Linear MCP server to user-scoped mcpServers (applies to all projects) 564 | func registerLinearToUserScope(settings map[string]interface{}, linearServerConfig map[string]interface{}) error { 565 | // Get or create user-scoped mcpServers 566 | var mcpServers map[string]interface{} 567 | if existingMcpServers, exists := settings["mcpServers"]; exists { 568 | if mcpServersMap, ok := existingMcpServers.(map[string]interface{}); ok { 569 | mcpServers = mcpServersMap 570 | } else { 571 | // If existing mcpServers is not a map, create a new one 572 | mcpServers = map[string]interface{}{} 573 | } 574 | } else { 575 | mcpServers = map[string]interface{}{} 576 | } 577 | 578 | // Add/update the linear server configuration 579 | mcpServers["linear"] = linearServerConfig 580 | settings["mcpServers"] = mcpServers 581 | 582 | return nil 583 | } 584 | 585 | // registerLinearToProject registers the Linear MCP server to a specific project 586 | func registerLinearToProject(settings map[string]interface{}, projectPath string, linearServerConfig map[string]interface{}) error { 587 | // Get projects map 588 | projects, ok := settings["projects"].(map[string]interface{}) 589 | if !ok { 590 | projects = map[string]interface{}{} 591 | settings["projects"] = projects 592 | } 593 | 594 | // Get or create the specific project 595 | var project map[string]interface{} 596 | if existingProject, exists := projects[projectPath]; exists { 597 | if projectMap, ok := existingProject.(map[string]interface{}); ok { 598 | project = projectMap 599 | } else { 600 | // If existing project is not a map, create a new one 601 | project = map[string]interface{}{} 602 | } 603 | } else { 604 | project = map[string]interface{}{} 605 | } 606 | 607 | // Get or create mcpServers for this project 608 | var mcpServers map[string]interface{} 609 | if existingMcpServers, exists := project["mcpServers"]; exists { 610 | if mcpServersMap, ok := existingMcpServers.(map[string]interface{}); ok { 611 | mcpServers = mcpServersMap 612 | } else { 613 | // If existing mcpServers is not a map, create a new one 614 | mcpServers = map[string]interface{}{} 615 | } 616 | } else { 617 | mcpServers = map[string]interface{}{} 618 | } 619 | 620 | // Add/update the linear server configuration 621 | mcpServers["linear"] = linearServerConfig 622 | project["mcpServers"] = mcpServers 623 | projects[projectPath] = project 624 | 625 | return nil 626 | } 627 | ``` -------------------------------------------------------------------------------- /pkg/server/tools_test.go: -------------------------------------------------------------------------------- ```go 1 | package server 2 | 3 | import ( 4 | "context" 5 | "path/filepath" 6 | "testing" 7 | 8 | "github.com/geropl/linear-mcp-go/pkg/linear" 9 | "github.com/geropl/linear-mcp-go/pkg/tools" 10 | "github.com/google/go-cmp/cmp" 11 | "github.com/mark3labs/mcp-go/mcp" 12 | ) 13 | 14 | // Shared constants and expectation struct are defined in test_helpers.go 15 | 16 | func TestHandlers(t *testing.T) { 17 | // Define test cases 18 | tests := []struct { 19 | handler string 20 | name string 21 | args map[string]interface{} 22 | write bool 23 | }{ 24 | // GetTeamsHandler test cases 25 | { 26 | handler: "get_teams", 27 | name: "Get Teams", 28 | args: map[string]interface{}{ 29 | "name": TEAM_NAME, 30 | }, 31 | }, 32 | // CreateIssueHandler test cases 33 | { 34 | handler: "create_issue", 35 | name: "Valid issue with team", 36 | args: map[string]interface{}{ 37 | "title": "Test Issue", 38 | "team": TEAM_ID, 39 | }, 40 | write: true, 41 | }, 42 | { 43 | handler: "create_issue", 44 | name: "Valid issue with team UUID", 45 | args: map[string]interface{}{ 46 | "title": "Test Issue with team UUID", 47 | "team": TEAM_ID, 48 | }, 49 | write: true, 50 | }, 51 | { 52 | handler: "create_issue", 53 | name: "Valid issue with team name", 54 | args: map[string]interface{}{ 55 | "title": "Test Issue with team name", 56 | "team": TEAM_NAME, 57 | }, 58 | write: true, 59 | }, 60 | { 61 | handler: "create_issue", 62 | name: "Valid issue with team key", 63 | args: map[string]interface{}{ 64 | "title": "Test Issue with team key", 65 | "team": TEAM_KEY, 66 | }, 67 | write: true, 68 | }, 69 | { 70 | handler: "create_issue", 71 | name: "Create sub issue", 72 | args: map[string]interface{}{ 73 | "title": "Sub Issue", 74 | "team": TEAM_ID, 75 | "makeSubissueOf": "1c2de93f-4321-4015-bfde-ee893ef7976f", // UUID for TEST-10 76 | }, 77 | write: true, 78 | }, 79 | { 80 | handler: "create_issue", 81 | name: "Create sub issue from identifier", 82 | args: map[string]interface{}{ 83 | "title": "Sub Issue", 84 | "team": TEAM_ID, 85 | "makeSubissueOf": "TEST-10", 86 | }, 87 | write: true, 88 | }, 89 | { 90 | handler: "create_issue", 91 | name: "Create issue with labels", 92 | args: map[string]interface{}{ 93 | "title": "Issue with Labels", 94 | "team": TEAM_ID, 95 | "labels": "team label 1", 96 | }, 97 | write: true, 98 | }, 99 | { 100 | handler: "create_issue", 101 | name: "Create sub issue with labels", 102 | args: map[string]interface{}{ 103 | "title": "Sub Issue with Labels", 104 | "team": TEAM_ID, 105 | "makeSubissueOf": "1c2de93f-4321-4015-bfde-ee893ef7976f", // UUID for TEST-10 106 | "labels": "ws-label 2,Feature", 107 | }, 108 | write: true, 109 | }, 110 | { 111 | handler: "create_issue", 112 | name: "Create issue with project ID", 113 | args: map[string]interface{}{ 114 | "title": "Issue with Project ID", 115 | "team": TEAM_ID, 116 | "project": PROJECT_ID, 117 | }, 118 | write: true, 119 | }, 120 | { 121 | handler: "create_issue", 122 | name: "Create issue with project name", 123 | args: map[string]interface{}{ 124 | "title": "Issue with Project Name", 125 | "team": TEAM_ID, 126 | "project": "MCP tool investigation", 127 | }, 128 | write: true, 129 | }, 130 | { 131 | handler: "create_issue", 132 | name: "Create issue with project slug", 133 | args: map[string]interface{}{ 134 | "title": "Issue with Project Slug", 135 | "team": TEAM_ID, 136 | "project": "mcp-tool-investigation-ae44897e42a7", 137 | }, 138 | write: true, 139 | }, 140 | { 141 | handler: "create_issue", 142 | name: "Create issue with invalid project", 143 | args: map[string]interface{}{ 144 | "title": "Issue with Invalid Project", 145 | "team": TEAM_ID, 146 | "project": "non-existent-project", 147 | }, 148 | write: true, 149 | }, 150 | { 151 | handler: "create_issue", 152 | name: "Missing title", 153 | args: map[string]interface{}{ 154 | "team": TEAM_ID, 155 | }, 156 | }, 157 | { 158 | handler: "create_issue", 159 | name: "Missing team", 160 | args: map[string]interface{}{ 161 | "title": "Test Issue", 162 | }, 163 | }, 164 | { 165 | handler: "create_issue", 166 | name: "Invalid team", 167 | args: map[string]interface{}{ 168 | "title": "Test Issue", 169 | "team": "NonExistentTeam", 170 | }, 171 | }, 172 | 173 | // UpdateIssueHandler test cases 174 | { 175 | handler: "update_issue", 176 | name: "Valid update", 177 | args: map[string]interface{}{ 178 | "issue": ISSUE_ID, 179 | "title": "Updated Test Issue", 180 | }, 181 | write: true, 182 | }, 183 | { 184 | handler: "update_issue", 185 | name: "Missing id", 186 | args: map[string]interface{}{ 187 | "title": "Updated Test Issue", 188 | }, 189 | }, 190 | 191 | // SearchIssuesHandler test cases 192 | { 193 | handler: "search_issues", 194 | name: "Search by team", 195 | args: map[string]interface{}{ 196 | "team": TEAM_ID, 197 | "limit": float64(5), 198 | }, 199 | }, 200 | { 201 | handler: "search_issues", 202 | name: "Search by query", 203 | args: map[string]interface{}{ 204 | "query": "test", 205 | "limit": float64(5), 206 | }, 207 | }, 208 | 209 | // GetUserIssuesHandler test cases 210 | { 211 | handler: "get_user_issues", 212 | name: "Current user issues", 213 | args: map[string]interface{}{ 214 | "limit": float64(5), 215 | }, 216 | }, 217 | { 218 | handler: "get_user_issues", 219 | name: "Specific user issues", 220 | args: map[string]interface{}{ 221 | "user": USER_ID, 222 | "limit": float64(5), 223 | }, 224 | }, 225 | 226 | // GetIssueHandler test cases 227 | { 228 | handler: "get_issue", 229 | name: "Valid issue", 230 | args: map[string]interface{}{ 231 | "issue": ISSUE_ID, 232 | }, 233 | }, 234 | { 235 | handler: "get_issue", 236 | name: "Get comment issue", 237 | args: map[string]interface{}{ 238 | "issue": COMMENT_ISSUE_ID, 239 | }, 240 | }, 241 | { 242 | handler: "get_issue", 243 | name: "Missing issue", 244 | args: map[string]interface{}{}, 245 | }, 246 | { 247 | handler: "get_issue", 248 | name: "Missing issueId", 249 | args: map[string]interface{}{ 250 | "issue": "NONEXISTENT-123", 251 | }, 252 | }, 253 | 254 | // GetIssueCommentsHandler test cases 255 | { 256 | handler: "get_issue_comments", 257 | name: "Valid issue", 258 | args: map[string]interface{}{ 259 | "issue": ISSUE_ID, 260 | }, 261 | }, 262 | { 263 | handler: "get_issue_comments", 264 | name: "Missing issue", 265 | args: map[string]interface{}{}, 266 | }, 267 | { 268 | handler: "get_issue_comments", 269 | name: "Invalid issue", 270 | args: map[string]interface{}{ 271 | "issue": "NONEXISTENT-123", 272 | }, 273 | }, 274 | { 275 | handler: "get_issue_comments", 276 | name: "With limit", 277 | args: map[string]interface{}{ 278 | "issue": ISSUE_ID, 279 | "limit": float64(3), 280 | }, 281 | }, 282 | { 283 | handler: "get_issue_comments", 284 | name: "With_thread_parameter", 285 | args: map[string]interface{}{ 286 | "issue": ISSUE_ID, 287 | "thread": "ae3d62d6-3f40-4990-867b-5c97dd265a40", // ID of a comment to get replies for 288 | }, 289 | }, 290 | { 291 | handler: "get_issue_comments", 292 | name: "Thread_with_pagination", 293 | args: map[string]interface{}{ 294 | "issue": ISSUE_ID, 295 | "thread": "ae3d62d6-3f40-4990-867b-5c97dd265a40", // ID of a comment to get replies for 296 | "limit": float64(2), 297 | }, 298 | }, 299 | 300 | // AddCommentHandler test cases 301 | { 302 | handler: "add_comment", 303 | name: "Valid comment", 304 | write: true, 305 | args: map[string]interface{}{ 306 | "issue": ISSUE_ID, 307 | "body": "Test comment", 308 | }, 309 | }, 310 | { 311 | handler: "add_comment", 312 | name: "Reply_to_comment", 313 | write: true, 314 | args: map[string]interface{}{ 315 | "issue": ISSUE_ID, 316 | "body": "This is a reply to the comment", 317 | "thread": "ae3d62d6-3f40-4990-867b-5c97dd265a40", // ID of the comment to reply to 318 | }, 319 | }, 320 | { 321 | handler: "add_comment", 322 | name: "Missing issue", 323 | args: map[string]interface{}{ 324 | "body": "Test comment", 325 | }, 326 | }, 327 | { 328 | handler: "add_comment", 329 | name: "Missing body", 330 | args: map[string]interface{}{ 331 | "issue": ISSUE_ID, 332 | }, 333 | }, 334 | { 335 | handler: "add_comment", 336 | name: "Reply with URL", 337 | write: true, 338 | args: map[string]interface{}{ 339 | "issue": ISSUE_ID, 340 | "body": "Reply using comment URL", 341 | "thread": "https://linear.app/linear-mcp-go-test/issue/TEST-10/updated-test-issue#comment-ae3d62d6", 342 | }, 343 | }, 344 | { 345 | handler: "add_comment", 346 | name: "Reply with shorthand", 347 | write: true, 348 | args: map[string]interface{}{ 349 | "issue": ISSUE_ID, 350 | "body": "Reply using shorthand", 351 | "thread": "comment-ae3d62d6", 352 | }, 353 | }, 354 | // ReplyToCommentHandler test cases 355 | { 356 | handler: "reply_to_comment", 357 | name: "Valid reply", 358 | write: true, 359 | args: map[string]interface{}{ 360 | "thread": "ae3d62d6-3f40-4990-867b-5c97dd265a40", 361 | "body": "This is a reply using the dedicated tool", 362 | }, 363 | }, 364 | { 365 | handler: "reply_to_comment", 366 | name: "Reply with URL", 367 | write: true, 368 | args: map[string]interface{}{ 369 | "thread": "https://linear.app/linear-mcp-go-test/issue/TEST-10/updated-test-issue#comment-ae3d62d6", 370 | "body": "Reply using URL in dedicated tool", 371 | }, 372 | }, 373 | { 374 | handler: "reply_to_comment", 375 | name: "Missing thread", 376 | args: map[string]interface{}{ 377 | "body": "Reply without thread", 378 | }, 379 | }, 380 | { 381 | handler: "reply_to_comment", 382 | name: "Missing body", 383 | args: map[string]interface{}{ 384 | "thread": "ae3d62d6-3f40-4990-867b-5c97dd265a40", 385 | }, 386 | }, 387 | // UpdateCommentHandler test cases 388 | { 389 | handler: "update_comment", 390 | name: "Valid comment update", 391 | write: true, 392 | args: map[string]interface{}{ 393 | "comment": "ae3d62d6-3f40-4990-867b-5c97dd265a40", 394 | "body": "Updated comment text", 395 | }, 396 | }, 397 | { 398 | handler: "update_comment", 399 | name: "Valid comment update with shorthand", 400 | write: true, 401 | args: map[string]interface{}{ 402 | "comment": "comment-ae3d62d6", 403 | "body": "Updated comment text via shorthand", 404 | }, 405 | }, 406 | { 407 | handler: "update_comment", 408 | name: "Valid comment update with hash only", 409 | write: true, 410 | args: map[string]interface{}{ 411 | "comment": "ae3d62d6", 412 | "body": "Updated comment text via hash", 413 | }, 414 | }, 415 | { 416 | handler: "update_comment", 417 | name: "Missing comment", 418 | args: map[string]interface{}{ 419 | "body": "Updated comment text", 420 | }, 421 | }, 422 | { 423 | handler: "update_comment", 424 | name: "Missing body", 425 | args: map[string]interface{}{ 426 | "comment": "ae3d62d6-3f40-4990-867b-5c97dd265a40", 427 | }, 428 | }, 429 | { 430 | handler: "update_comment", 431 | name: "Invalid comment identifier", 432 | write: true, 433 | args: map[string]interface{}{ 434 | "comment": "invalid-comment-id", 435 | "body": "Updated comment text", 436 | }, 437 | }, 438 | // GetProjectHandler test cases 439 | { 440 | handler: "get_project", 441 | name: "By ID", 442 | args: map[string]interface{}{ 443 | "project": "01bff2dd-ab7f-4464-b425-97073862013f", 444 | }, 445 | }, 446 | { 447 | handler: "get_project", 448 | name: "Missing project param", 449 | args: map[string]interface{}{}, 450 | }, 451 | { 452 | handler: "get_project", 453 | name: "Invalid project", 454 | args: map[string]interface{}{ 455 | "project": "NONEXISTENT-PROJECT", 456 | }, 457 | }, 458 | { 459 | handler: "get_project", 460 | name: "By slug", 461 | args: map[string]interface{}{ 462 | "project": "mcp-tool-investigation-ae44897e42a7", 463 | }, 464 | }, 465 | { 466 | handler: "get_project", 467 | name: "By name", 468 | args: map[string]interface{}{ 469 | "project": "MCP tool investigation", 470 | }, 471 | }, 472 | { 473 | handler: "get_project", 474 | name: "Non-existent slug", 475 | args: map[string]interface{}{ 476 | "project": "non-existent-slug", 477 | }, 478 | }, 479 | // SearchProjectsHandler test cases 480 | { 481 | handler: "search_projects", 482 | name: "Empty query", 483 | args: map[string]interface{}{ 484 | "query": "", 485 | }, 486 | }, 487 | { 488 | handler: "search_projects", 489 | name: "No results", 490 | args: map[string]interface{}{ 491 | "query": "non-existent-project-query", 492 | }, 493 | }, 494 | { 495 | handler: "search_projects", 496 | name: "Multiple results", 497 | args: map[string]interface{}{ 498 | "query": "MCP", 499 | }, 500 | }, 501 | // CreateProjectHandler test cases 502 | { 503 | handler: "create_project", 504 | name: "Valid project", 505 | args: map[string]interface{}{ 506 | "name": "Created Test Project", 507 | "teamIds": TEAM_ID, 508 | }, 509 | write: true, 510 | }, 511 | { 512 | handler: "create_project", 513 | name: "With all optional fields", 514 | args: map[string]interface{}{ 515 | "name": "Test Project 2", 516 | "teamIds": TEAM_ID, 517 | "description": "Test Description", 518 | "leadId": USER_ID, 519 | "startDate": "2024-01-01", 520 | "targetDate": "2024-12-31", 521 | }, 522 | write: true, 523 | }, 524 | { 525 | handler: "create_project", 526 | name: "Missing name", 527 | args: map[string]interface{}{ 528 | "teamIds": TEAM_ID, 529 | }, 530 | write: true, 531 | }, 532 | { 533 | handler: "create_project", 534 | name: "Invalid team ID", 535 | args: map[string]interface{}{ 536 | "name": "Test Project 3", 537 | "teamIds": "invalid-team-id", 538 | }, 539 | write: true, 540 | }, 541 | // UpdateProjectHandler test cases 542 | { 543 | handler: "update_project", 544 | name: "Valid update", 545 | args: map[string]interface{}{ 546 | "project": UPDATE_PROJECT_ID, 547 | "name": "Updated Project Name", 548 | }, 549 | write: true, 550 | }, 551 | { 552 | handler: "update_project", 553 | name: "Update name and description", 554 | args: map[string]interface{}{ 555 | "project": UPDATE_PROJECT_ID, 556 | "name": "Updated Project Name 2", 557 | "description": "Updated Description", 558 | }, 559 | write: true, 560 | }, 561 | { 562 | handler: "update_project", 563 | name: "Non-existent project", 564 | args: map[string]interface{}{ 565 | "project": "non-existent-project", 566 | "name": "Updated Project Name", 567 | }, 568 | write: true, 569 | }, 570 | { 571 | handler: "update_project", 572 | name: "Update only description", 573 | args: map[string]interface{}{ 574 | "project": UPDATE_PROJECT_ID, 575 | "description": "Updated Description Only", 576 | }, 577 | write: true, 578 | }, 579 | // GetMilestoneHandler test cases 580 | { 581 | handler: "get_milestone", 582 | name: "Valid milestone", 583 | args: map[string]interface{}{ 584 | "milestone": MILESTONE_ID, 585 | }, 586 | }, 587 | { 588 | handler: "get_milestone", 589 | name: "By name", 590 | args: map[string]interface{}{ 591 | "milestone": "Test Milestone 2", 592 | }, 593 | }, 594 | { 595 | handler: "get_milestone", 596 | name: "Non-existent milestone", 597 | args: map[string]interface{}{ 598 | "milestone": "non-existent-milestone", 599 | }, 600 | }, 601 | // CreateMilestoneHandler test cases 602 | { 603 | handler: "create_milestone", 604 | name: "Valid milestone", 605 | args: map[string]interface{}{ 606 | "name": "Test Milestone 2.2", 607 | "projectId": UPDATE_PROJECT_ID, 608 | }, 609 | write: true, 610 | }, 611 | { 612 | handler: "create_milestone", 613 | name: "With all optional fields", 614 | args: map[string]interface{}{ 615 | "name": "Test Milestone 3.2", 616 | "projectId": UPDATE_PROJECT_ID, 617 | "description": "Test Description", 618 | "targetDate": "2024-12-31", 619 | }, 620 | write: true, 621 | }, 622 | { 623 | handler: "create_milestone", 624 | name: "Missing name", 625 | args: map[string]interface{}{ 626 | "projectId": UPDATE_PROJECT_ID, 627 | }, 628 | write: true, 629 | }, 630 | { 631 | handler: "create_milestone", 632 | name: "Invalid project ID", 633 | args: map[string]interface{}{ 634 | "name": "Test Milestone 3.1", 635 | "projectId": "invalid-project-id", 636 | }, 637 | write: true, 638 | }, 639 | // UpdateMilestoneHandler test cases 640 | { 641 | handler: "update_milestone", 642 | name: "Valid update", 643 | args: map[string]interface{}{ 644 | "milestone": UPDATE_MILESTONE_ID, 645 | "name": "Updated Milestone Name 22", 646 | "description": "Updated Description", 647 | "targetDate": "2025-01-01", 648 | }, 649 | write: true, 650 | }, 651 | { 652 | handler: "update_milestone", 653 | name: "Non-existent milestone", 654 | args: map[string]interface{}{ 655 | "milestone": "non-existent-milestone", 656 | "name": "Updated Milestone Name", 657 | }, 658 | write: true, 659 | }, 660 | // GetInitiativeHandler test cases 661 | { 662 | handler: "get_initiative", 663 | name: "Valid initiative", 664 | args: map[string]interface{}{ 665 | "initiative": INITIATIVE_ID, 666 | }, 667 | }, 668 | { 669 | handler: "get_initiative", 670 | name: "By name", 671 | args: map[string]interface{}{ 672 | "initiative": "Push for MCP", 673 | }, 674 | }, 675 | { 676 | handler: "get_initiative", 677 | name: "Non-existent name", 678 | args: map[string]interface{}{ 679 | "initiative": "non-existent-name", 680 | }, 681 | }, 682 | // CreateInitiativeHandler test cases 683 | { 684 | handler: "create_initiative", 685 | name: "Valid initiative", 686 | args: map[string]interface{}{ 687 | "name": "Created Test Initiative", 688 | }, 689 | write: true, 690 | }, 691 | { 692 | handler: "create_initiative", 693 | name: "With description", 694 | args: map[string]interface{}{ 695 | "name": "Created Test Initiative 2", 696 | "description": "Test Description", 697 | }, 698 | write: true, 699 | }, 700 | { 701 | handler: "create_initiative", 702 | name: "Missing name", 703 | args: map[string]interface{}{}, 704 | write: true, 705 | }, 706 | // UpdateInitiativeHandler test cases 707 | { 708 | handler: "update_initiative", 709 | name: "Valid update", 710 | args: map[string]interface{}{ 711 | "initiative": UPDATE_INITIATIVE_ID, 712 | "name": "Updated Initiative Name", 713 | "description": "Updated Description", 714 | }, 715 | write: true, 716 | }, 717 | { 718 | handler: "update_initiative", 719 | name: "Non-existent initiative", 720 | args: map[string]interface{}{ 721 | "initiative": "non-existent-initiative", 722 | "name": "Updated Initiative Name", 723 | }, 724 | write: true, 725 | }, 726 | } 727 | 728 | for _, tt := range tests { 729 | t.Run(tt.handler+"_"+tt.name, func(t *testing.T) { 730 | if tt.write && *record && !*recordWrites { 731 | t.Skip("Skipping write test when recordWrites=false") 732 | return 733 | } 734 | 735 | // Generate golden file path 736 | goldenPath := filepath.Join("../../testdata/golden", tt.handler+"_handler_"+tt.name+".golden") 737 | 738 | // Create test client 739 | client, cleanup := linear.NewTestClient(t, tt.handler+"_handler_"+tt.name, *record || *recordWrites) 740 | defer cleanup() 741 | 742 | // Create the appropriate handler based on tt.handler 743 | var handler func(context.Context, mcp.CallToolRequest) (*mcp.CallToolResult, error) 744 | switch tt.handler { 745 | case "get_teams": 746 | handler = tools.GetTeamsHandler(client) 747 | case "create_issue": 748 | handler = tools.CreateIssueHandler(client) 749 | case "update_issue": 750 | handler = tools.UpdateIssueHandler(client) 751 | case "search_issues": 752 | handler = tools.SearchIssuesHandler(client) 753 | case "get_user_issues": 754 | handler = tools.GetUserIssuesHandler(client) 755 | case "get_issue": 756 | handler = tools.GetIssueHandler(client) 757 | case "get_issue_comments": 758 | handler = tools.GetIssueCommentsHandler(client) 759 | case "add_comment": 760 | handler = tools.AddCommentHandler(client) 761 | case "reply_to_comment": 762 | handler = tools.ReplyToCommentHandler(client) 763 | case "update_comment": 764 | handler = tools.UpdateCommentHandler(client) 765 | case "get_project": 766 | handler = tools.GetProjectHandler(client) 767 | case "search_projects": 768 | handler = tools.SearchProjectsHandler(client) 769 | case "create_project": 770 | handler = tools.CreateProjectHandler(client) 771 | case "update_project": 772 | handler = tools.UpdateProjectHandler(client) 773 | case "get_milestone": 774 | handler = tools.GetMilestoneHandler(client) 775 | case "create_milestone": 776 | handler = tools.CreateMilestoneHandler(client) 777 | case "update_milestone": 778 | handler = tools.UpdateMilestoneHandler(client) 779 | case "get_initiative": 780 | handler = tools.GetInitiativeHandler(client) 781 | case "create_initiative": 782 | handler = tools.CreateInitiativeHandler(client) 783 | case "update_initiative": 784 | handler = tools.UpdateInitiativeHandler(client) 785 | default: 786 | t.Fatalf("Unknown handler type: %s", tt.handler) 787 | } 788 | 789 | // Create the request 790 | request := mcp.CallToolRequest{} 791 | request.Params.Name = "linear_" + tt.handler 792 | request.Params.Arguments = tt.args 793 | 794 | // Call the handler 795 | result, err := handler(context.Background(), request) 796 | 797 | // Check for errors 798 | if err != nil { 799 | t.Fatalf("Handler returned error: %v", err) 800 | } 801 | 802 | // Extract the actual output and error 803 | var actualOutput, actualErr string 804 | if result.IsError { 805 | // For error results, the error message is in the text content 806 | for _, content := range result.Content { 807 | if textContent, ok := content.(mcp.TextContent); ok { 808 | actualErr = textContent.Text 809 | break 810 | } 811 | } 812 | } else { 813 | // For success results, the output is in the text content 814 | for _, content := range result.Content { 815 | if textContent, ok := content.(mcp.TextContent); ok { 816 | actualOutput = textContent.Text 817 | break 818 | } 819 | } 820 | } 821 | 822 | // If golden flag is set, update the golden file 823 | if *golden { 824 | writeGoldenFile(t, goldenPath, expectation{ 825 | Err: actualErr, 826 | Output: actualOutput, 827 | }) 828 | return 829 | } 830 | 831 | // Otherwise, read the golden file and compare 832 | expected := readGoldenFile(t, goldenPath) 833 | 834 | // Compare error 835 | if diff := cmp.Diff(expected.Err, actualErr); diff != "" { 836 | t.Errorf("Error mismatch (-want +got):\n%s", diff) 837 | } 838 | 839 | // Compare output (only if no error is expected) 840 | if expected.Err == "" { 841 | if diff := cmp.Diff(expected.Output, actualOutput); diff != "" { 842 | t.Errorf("Output mismatch (-want +got):\n%s", diff) 843 | } 844 | } 845 | }) 846 | } 847 | } 848 | 849 | // readGoldenFile and writeGoldenFile are defined in test_helpers.go 850 | ``` -------------------------------------------------------------------------------- /cmd/setup_test.go: -------------------------------------------------------------------------------- ```go 1 | package cmd 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "os" 9 | "os/exec" 10 | "path/filepath" 11 | "sort" 12 | "strings" 13 | "testing" 14 | 15 | "github.com/google/go-cmp/cmp" 16 | ) 17 | 18 | // Define expectation types 19 | type fileExpectation struct { 20 | path string 21 | content string 22 | mustExist bool 23 | } 24 | 25 | type preExistingFile struct { 26 | path string 27 | content string 28 | } 29 | 30 | type expectations struct { 31 | files map[string]fileExpectation 32 | errors []string 33 | exitCode int 34 | } 35 | 36 | // TestSetupCommand tests the setup command with various combinations of parameters 37 | func TestSetupCommand(t *testing.T) { 38 | // Build the binary 39 | binaryPath, err := buildBinary() 40 | if err != nil { 41 | t.Fatalf("Failed to build binary: %v", err) 42 | } 43 | defer os.RemoveAll(filepath.Dir(binaryPath)) 44 | 45 | // Define test cases 46 | testCases := []struct { 47 | name string 48 | toolParam string 49 | writeAccess bool 50 | autoApprove string 51 | projectPath string 52 | preExistingFiles map[string]preExistingFile 53 | expect expectations 54 | }{ 55 | { 56 | name: "Cline Only", 57 | toolParam: "cline", 58 | writeAccess: true, 59 | autoApprove: "allow-read-only", 60 | expect: expectations{ 61 | files: map[string]fileExpectation{ 62 | "cline": { 63 | path: "home/.vscode-server/data/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json", 64 | mustExist: true, 65 | content: `{ 66 | "mcpServers": { 67 | "linear": { 68 | "command": "home/mcp-servers/linear-mcp-go", 69 | "args": ["serve", "--write-access=true"], 70 | "env": { 71 | "LINEAR_API_KEY": "test-api-key" 72 | }, 73 | "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"], 74 | "disabled": false 75 | } 76 | } 77 | }`, 78 | }, 79 | }, 80 | exitCode: 0, 81 | }, 82 | }, 83 | { 84 | name: "Roo Code Only", 85 | toolParam: "roo-code", 86 | writeAccess: true, 87 | autoApprove: "allow-read-only", 88 | expect: expectations{ 89 | files: map[string]fileExpectation{ 90 | "roo-code": { 91 | path: "home/.vscode-server/data/User/globalStorage/rooveterinaryinc.roo-cline/settings/cline_mcp_settings.json", 92 | mustExist: true, 93 | content: `{ 94 | "mcpServers": { 95 | "linear": { 96 | "command": "home/mcp-servers/linear-mcp-go", 97 | "args": ["serve", "--write-access=true"], 98 | "env": { 99 | "LINEAR_API_KEY": "test-api-key" 100 | }, 101 | "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"], 102 | "disabled": false 103 | } 104 | } 105 | }`, 106 | }, 107 | }, 108 | exitCode: 0, 109 | }, 110 | }, 111 | { 112 | name: "Multiple Tools", 113 | toolParam: "cline,roo-code", 114 | writeAccess: true, 115 | autoApprove: "allow-read-only", 116 | expect: expectations{ 117 | files: map[string]fileExpectation{ 118 | "cline": { 119 | path: "home/.vscode-server/data/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json", 120 | mustExist: true, 121 | content: `{ 122 | "mcpServers": { 123 | "linear": { 124 | "command": "home/mcp-servers/linear-mcp-go", 125 | "args": ["serve", "--write-access=true"], 126 | "env": { 127 | "LINEAR_API_KEY": "test-api-key" 128 | }, 129 | "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"], 130 | "disabled": false 131 | } 132 | } 133 | }`, 134 | }, 135 | "roo-code": { 136 | path: "home/.vscode-server/data/User/globalStorage/rooveterinaryinc.roo-cline/settings/cline_mcp_settings.json", 137 | mustExist: true, 138 | content: `{ 139 | "mcpServers": { 140 | "linear": { 141 | "command": "home/mcp-servers/linear-mcp-go", 142 | "args": ["serve", "--write-access=true"], 143 | "env": { 144 | "LINEAR_API_KEY": "test-api-key" 145 | }, 146 | "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"], 147 | "disabled": false 148 | } 149 | } 150 | }`, 151 | }, 152 | }, 153 | exitCode: 0, 154 | }, 155 | }, 156 | { 157 | name: "Invalid Tool", 158 | toolParam: "invalid-tool,cline", 159 | writeAccess: true, 160 | autoApprove: "allow-read-only", 161 | expect: expectations{ 162 | files: map[string]fileExpectation{ 163 | "cline": { 164 | path: "home/.vscode-server/data/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json", 165 | mustExist: true, 166 | content: `{ 167 | "mcpServers": { 168 | "linear": { 169 | "command": "home/mcp-servers/linear-mcp-go", 170 | "args": ["serve", "--write-access=true"], 171 | "env": { 172 | "LINEAR_API_KEY": "test-api-key" 173 | }, 174 | "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"], 175 | "disabled": false 176 | } 177 | } 178 | }`, 179 | }, 180 | }, 181 | errors: []string{"Unsupported tool: invalid-tool"}, 182 | exitCode: 1, 183 | }, 184 | }, 185 | { 186 | name: "Preserve Existing Arrays in Config", 187 | toolParam: "cline", 188 | writeAccess: true, 189 | autoApprove: "allow-read-only", 190 | preExistingFiles: map[string]preExistingFile{ 191 | "cline": { 192 | path: "home/.vscode-server/data/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json", 193 | content: `{ 194 | "mcpServers": { 195 | "existing-server": { 196 | "command": "/path/to/existing/server", 197 | "args": ["serve", "--option1", "--option2"], 198 | "autoApprove": ["tool1", "tool2", "tool3"], 199 | "env": { 200 | "API_KEY": "existing-key" 201 | }, 202 | "disabled": false, 203 | "customArray": ["item1", "item2"], 204 | "nestedObject": { 205 | "arrayField": ["nested1", "nested2"] 206 | } 207 | } 208 | }, 209 | "otherTopLevelArray": ["value1", "value2"], 210 | "otherConfig": { 211 | "someArray": [1, 2, 3] 212 | } 213 | }`, 214 | }, 215 | }, 216 | expect: expectations{ 217 | files: map[string]fileExpectation{ 218 | "cline": { 219 | path: "home/.vscode-server/data/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json", 220 | mustExist: true, 221 | content: `{ 222 | "mcpServers": { 223 | "existing-server": { 224 | "command": "/path/to/existing/server", 225 | "args": ["serve", "--option1", "--option2"], 226 | "autoApprove": ["tool1", "tool2", "tool3"], 227 | "env": { 228 | "API_KEY": "existing-key" 229 | }, 230 | "disabled": false, 231 | "customArray": ["item1", "item2"], 232 | "nestedObject": { 233 | "arrayField": ["nested1", "nested2"] 234 | } 235 | }, 236 | "linear": { 237 | "command": "home/mcp-servers/linear-mcp-go", 238 | "args": ["serve", "--write-access=true"], 239 | "env": { 240 | "LINEAR_API_KEY": "test-api-key" 241 | }, 242 | "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"], 243 | "disabled": false 244 | } 245 | }, 246 | "otherTopLevelArray": ["value1", "value2"], 247 | "otherConfig": { 248 | "someArray": [1, 2, 3] 249 | } 250 | }`, 251 | }, 252 | }, 253 | exitCode: 0, 254 | }, 255 | }, 256 | { 257 | name: "Complex Array Preservation Test", 258 | toolParam: "cline", 259 | writeAccess: false, 260 | autoApprove: "linear_get_issue,linear_search_issues", 261 | preExistingFiles: map[string]preExistingFile{ 262 | "cline": { 263 | path: "home/.vscode-server/data/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json", 264 | content: `{ 265 | "mcpServers": { 266 | "github": { 267 | "command": "npx", 268 | "args": ["-y", "@modelcontextprotocol/server-github"], 269 | "env": { 270 | "GITHUB_PERSONAL_ACCESS_TOKEN": "github_token" 271 | }, 272 | "autoApprove": ["search_repositories", "get_file_contents"], 273 | "disabled": false 274 | }, 275 | "filesystem": { 276 | "command": "npx", 277 | "args": ["-y", "@modelcontextprotocol/server-filesystem", "/workspace"], 278 | "autoApprove": [], 279 | "disabled": false 280 | } 281 | }, 282 | "globalSettings": { 283 | "enabledFeatures": ["autocomplete", "syntax-highlighting"], 284 | "debugModes": ["verbose", "trace"] 285 | } 286 | }`, 287 | }, 288 | }, 289 | expect: expectations{ 290 | files: map[string]fileExpectation{ 291 | "cline": { 292 | path: "home/.vscode-server/data/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json", 293 | mustExist: true, 294 | content: `{ 295 | "mcpServers": { 296 | "github": { 297 | "command": "npx", 298 | "args": ["-y", "@modelcontextprotocol/server-github"], 299 | "env": { 300 | "GITHUB_PERSONAL_ACCESS_TOKEN": "github_token" 301 | }, 302 | "autoApprove": ["search_repositories", "get_file_contents"], 303 | "disabled": false 304 | }, 305 | "filesystem": { 306 | "command": "npx", 307 | "args": ["-y", "@modelcontextprotocol/server-filesystem", "/workspace"], 308 | "autoApprove": [], 309 | "disabled": false 310 | }, 311 | "linear": { 312 | "command": "home/mcp-servers/linear-mcp-go", 313 | "args": ["serve"], 314 | "env": { 315 | "LINEAR_API_KEY": "test-api-key" 316 | }, 317 | "autoApprove": ["linear_get_issue", "linear_search_issues"], 318 | "disabled": false 319 | } 320 | }, 321 | "globalSettings": { 322 | "enabledFeatures": ["autocomplete", "syntax-highlighting"], 323 | "debugModes": ["verbose", "trace"] 324 | } 325 | }`, 326 | }, 327 | }, 328 | exitCode: 0, 329 | }, 330 | }, 331 | { 332 | name: "Claude Code Only", 333 | toolParam: "claude-code", 334 | projectPath: "/workspace/test-project", 335 | expect: expectations{ 336 | files: map[string]fileExpectation{ 337 | "claude-code": { 338 | path: "home/.claude.json", 339 | mustExist: true, 340 | content: `{ 341 | "projects": { 342 | "/workspace/test-project": { 343 | "mcpServers": { 344 | "linear": { 345 | "type": "stdio", 346 | "command": "home/mcp-servers/linear-mcp-go", 347 | "args": ["serve"], 348 | "env": { 349 | "LINEAR_API_KEY": "test-api-key" 350 | }, 351 | "autoApprove": [], 352 | "disabled": false 353 | } 354 | } 355 | } 356 | } 357 | }`, 358 | }, 359 | }, 360 | exitCode: 0, 361 | }, 362 | }, 363 | { 364 | name: "Claude Code with Existing File", 365 | toolParam: "claude-code", 366 | projectPath: "/workspace/test-project", 367 | preExistingFiles: map[string]preExistingFile{ 368 | "claude-code": { 369 | path: "home/.claude.json", 370 | content: `{ 371 | "projects": { 372 | "/workspace/another-project": { 373 | "mcpServers": { 374 | "another-server": { 375 | "command": "/path/to/another/server" 376 | } 377 | } 378 | } 379 | } 380 | }`, 381 | }, 382 | }, 383 | expect: expectations{ 384 | files: map[string]fileExpectation{ 385 | "claude-code": { 386 | path: "home/.claude.json", 387 | mustExist: true, 388 | content: `{ 389 | "projects": { 390 | "/workspace/another-project": { 391 | "mcpServers": { 392 | "another-server": { 393 | "command": "/path/to/another/server" 394 | } 395 | } 396 | }, 397 | "/workspace/test-project": { 398 | "mcpServers": { 399 | "linear": { 400 | "type": "stdio", 401 | "command": "home/mcp-servers/linear-mcp-go", 402 | "args": ["serve"], 403 | "env": { 404 | "LINEAR_API_KEY": "test-api-key" 405 | }, 406 | "autoApprove": [], 407 | "disabled": false 408 | } 409 | } 410 | } 411 | } 412 | }`, 413 | }, 414 | }, 415 | exitCode: 0, 416 | }, 417 | }, 418 | { 419 | name: "Claude Code No Existing Projects and No Project Path - User Scope Registration", 420 | toolParam: "claude-code", 421 | expect: expectations{ 422 | files: map[string]fileExpectation{ 423 | "claude-code": { 424 | path: "home/.claude.json", 425 | mustExist: true, 426 | content: `{ 427 | "projects": {}, 428 | "mcpServers": { 429 | "linear": { 430 | "type": "stdio", 431 | "command": "home/mcp-servers/linear-mcp-go", 432 | "args": ["serve"], 433 | "env": { 434 | "LINEAR_API_KEY": "test-api-key" 435 | }, 436 | "autoApprove": [], 437 | "disabled": false 438 | } 439 | } 440 | }`, 441 | }, 442 | }, 443 | exitCode: 0, 444 | }, 445 | }, 446 | { 447 | name: "Claude Code Register to User Scope with Existing Projects", 448 | toolParam: "claude-code", 449 | preExistingFiles: map[string]preExistingFile{ 450 | "claude-code": { 451 | path: "home/.claude.json", 452 | content: `{ 453 | "projects": { 454 | "/workspace/project1": { 455 | "mcpServers": { 456 | "existing-server": { 457 | "command": "/path/to/existing/server" 458 | } 459 | } 460 | }, 461 | "/workspace/project2": { 462 | "someOtherConfig": "value" 463 | } 464 | } 465 | }`, 466 | }, 467 | }, 468 | expect: expectations{ 469 | files: map[string]fileExpectation{ 470 | "claude-code": { 471 | path: "home/.claude.json", 472 | mustExist: true, 473 | content: `{ 474 | "projects": { 475 | "/workspace/project1": { 476 | "mcpServers": { 477 | "existing-server": { 478 | "command": "/path/to/existing/server" 479 | } 480 | } 481 | }, 482 | "/workspace/project2": { 483 | "someOtherConfig": "value" 484 | } 485 | }, 486 | "mcpServers": { 487 | "linear": { 488 | "type": "stdio", 489 | "command": "home/mcp-servers/linear-mcp-go", 490 | "args": ["serve"], 491 | "env": { 492 | "LINEAR_API_KEY": "test-api-key" 493 | }, 494 | "autoApprove": [], 495 | "disabled": false 496 | } 497 | } 498 | }`, 499 | }, 500 | }, 501 | exitCode: 0, 502 | }, 503 | }, 504 | { 505 | name: "Claude Code Multiple Project Paths", 506 | toolParam: "claude-code", 507 | projectPath: "/workspace/proj1,/workspace/proj2, /workspace/proj3 ", 508 | writeAccess: true, 509 | autoApprove: "linear_get_issue,linear_search_issues", 510 | expect: expectations{ 511 | files: map[string]fileExpectation{ 512 | "claude-code": { 513 | path: "home/.claude.json", 514 | mustExist: true, 515 | content: `{ 516 | "projects": { 517 | "/workspace/proj1": { 518 | "mcpServers": { 519 | "linear": { 520 | "type": "stdio", 521 | "command": "home/mcp-servers/linear-mcp-go", 522 | "args": ["serve", "--write-access=true"], 523 | "env": { 524 | "LINEAR_API_KEY": "test-api-key" 525 | }, 526 | "autoApprove": ["linear_get_issue", "linear_search_issues"], 527 | "disabled": false 528 | } 529 | } 530 | }, 531 | "/workspace/proj2": { 532 | "mcpServers": { 533 | "linear": { 534 | "type": "stdio", 535 | "command": "home/mcp-servers/linear-mcp-go", 536 | "args": ["serve", "--write-access=true"], 537 | "env": { 538 | "LINEAR_API_KEY": "test-api-key" 539 | }, 540 | "autoApprove": ["linear_get_issue", "linear_search_issues"], 541 | "disabled": false 542 | } 543 | } 544 | }, 545 | "/workspace/proj3": { 546 | "mcpServers": { 547 | "linear": { 548 | "type": "stdio", 549 | "command": "home/mcp-servers/linear-mcp-go", 550 | "args": ["serve", "--write-access=true"], 551 | "env": { 552 | "LINEAR_API_KEY": "test-api-key" 553 | }, 554 | "autoApprove": ["linear_get_issue", "linear_search_issues"], 555 | "disabled": false 556 | } 557 | } 558 | } 559 | } 560 | }`, 561 | }, 562 | }, 563 | exitCode: 0, 564 | }, 565 | }, 566 | { 567 | name: "Claude Code Mixed Existing and New Projects", 568 | toolParam: "claude-code", 569 | projectPath: "/workspace/existing-project,/workspace/new-project", 570 | preExistingFiles: map[string]preExistingFile{ 571 | "claude-code": { 572 | path: "home/.claude.json", 573 | content: `{ 574 | "projects": { 575 | "/workspace/existing-project": { 576 | "mcpServers": { 577 | "other-server": { 578 | "command": "/path/to/other/server" 579 | } 580 | }, 581 | "customConfig": "value" 582 | } 583 | } 584 | }`, 585 | }, 586 | }, 587 | expect: expectations{ 588 | files: map[string]fileExpectation{ 589 | "claude-code": { 590 | path: "home/.claude.json", 591 | mustExist: true, 592 | content: `{ 593 | "projects": { 594 | "/workspace/existing-project": { 595 | "mcpServers": { 596 | "other-server": { 597 | "command": "/path/to/other/server" 598 | }, 599 | "linear": { 600 | "type": "stdio", 601 | "command": "home/mcp-servers/linear-mcp-go", 602 | "args": ["serve"], 603 | "env": { 604 | "LINEAR_API_KEY": "test-api-key" 605 | }, 606 | "autoApprove": [], 607 | "disabled": false 608 | } 609 | }, 610 | "customConfig": "value" 611 | }, 612 | "/workspace/new-project": { 613 | "mcpServers": { 614 | "linear": { 615 | "type": "stdio", 616 | "command": "home/mcp-servers/linear-mcp-go", 617 | "args": ["serve"], 618 | "env": { 619 | "LINEAR_API_KEY": "test-api-key" 620 | }, 621 | "autoApprove": [], 622 | "disabled": false 623 | } 624 | } 625 | } 626 | } 627 | }`, 628 | }, 629 | }, 630 | exitCode: 0, 631 | }, 632 | }, 633 | { 634 | name: "Claude Code Empty Project Path List", 635 | toolParam: "claude-code", 636 | projectPath: " , , ", 637 | expect: expectations{ 638 | errors: []string{"no valid project paths provided"}, 639 | exitCode: 1, 640 | }, 641 | }, 642 | { 643 | name: "Claude Code Complex Settings Preservation", 644 | toolParam: "claude-code", 645 | projectPath: "/workspace/new-project", 646 | writeAccess: true, 647 | autoApprove: "linear_get_issue,linear_search_issues", 648 | preExistingFiles: map[string]preExistingFile{ 649 | "claude-code": { 650 | path: "home/.claude.json", 651 | content: `{ 652 | "firstStartTime": "2025-06-11T14:49:28.932Z", 653 | "userID": "31553dcf54399f00daf126faf48dbb0e626926f50e9bf49c16cb05c06f65cfd8", 654 | "globalSettings": { 655 | "theme": "dark", 656 | "autoSave": true, 657 | "debugMode": false, 658 | "experimentalFeatures": ["feature1", "feature2", "feature3"], 659 | "limits": { 660 | "maxTokens": 4096, 661 | "timeout": 30000, 662 | "retries": 3 663 | }, 664 | "customMappings": { 665 | "shortcuts": { 666 | "ctrl+s": "save", 667 | "ctrl+z": "undo" 668 | }, 669 | "aliases": ["alias1", "alias2"] 670 | } 671 | }, 672 | "recentProjects": ["/workspace/project1", "/workspace/project2", "/workspace/project3"], 673 | "projects": { 674 | "/workspace/existing-project": { 675 | "allowedTools": ["tool1", "tool2", "tool3"], 676 | "history": [ 677 | { 678 | "timestamp": "2025-06-11T15:00:00.000Z", 679 | "action": "create_file", 680 | "details": {"filename": "test.js", "size": 1024} 681 | } 682 | ], 683 | "dontCrawlDirectory": false, 684 | "mcpContextUris": ["file:///workspace/docs", "https://api.example.com/docs"], 685 | "mcpServers": { 686 | "github": { 687 | "type": "stdio", 688 | "command": "npx", 689 | "args": ["-y", "@modelcontextprotocol/server-github"], 690 | "env": {"GITHUB_PERSONAL_ACCESS_TOKEN": "github_token_123"}, 691 | "autoApprove": ["search_repositories", "get_file_contents"], 692 | "disabled": false, 693 | "customConfig": {"rateLimit": 5000, "features": ["search", "read"]} 694 | }, 695 | "filesystem": { 696 | "type": "stdio", 697 | "command": "npx", 698 | "args": ["-y", "@modelcontextprotocol/server-filesystem", "/workspace"], 699 | "autoApprove": ["read_file", "list_directory"], 700 | "disabled": false, 701 | "permissions": {"read": true, "write": false, "execute": false} 702 | } 703 | }, 704 | "enabledMcpjsonServers": ["server1", "server2"], 705 | "disabledMcpjsonServers": ["server3", "server4"], 706 | "hasTrustDialogAccepted": true, 707 | "projectOnboardingSeenCount": 3, 708 | "customProjectSettings": { 709 | "linting": {"enabled": true, "rules": ["rule1", "rule2"]}, 710 | "formatting": {"tabSize": 2, "insertSpaces": true} 711 | } 712 | } 713 | }, 714 | "analytics": { 715 | "enabled": true, 716 | "sessionId": "session_12345", 717 | "metrics": {"commandsExecuted": 42, "filesModified": 15} 718 | }, 719 | "version": "1.2.3" 720 | }`, 721 | }, 722 | }, 723 | expect: expectations{ 724 | files: map[string]fileExpectation{ 725 | "claude-code": { 726 | path: "home/.claude.json", 727 | mustExist: true, 728 | content: `{ 729 | "firstStartTime": "2025-06-11T14:49:28.932Z", 730 | "userID": "31553dcf54399f00daf126faf48dbb0e626926f50e9bf49c16cb05c06f65cfd8", 731 | "globalSettings": { 732 | "theme": "dark", 733 | "autoSave": true, 734 | "debugMode": false, 735 | "experimentalFeatures": ["feature1", "feature2", "feature3"], 736 | "limits": { 737 | "maxTokens": 4096, 738 | "timeout": 30000, 739 | "retries": 3 740 | }, 741 | "customMappings": { 742 | "shortcuts": { 743 | "ctrl+s": "save", 744 | "ctrl+z": "undo" 745 | }, 746 | "aliases": ["alias1", "alias2"] 747 | } 748 | }, 749 | "recentProjects": ["/workspace/project1", "/workspace/project2", "/workspace/project3"], 750 | "projects": { 751 | "/workspace/existing-project": { 752 | "allowedTools": ["tool1", "tool2", "tool3"], 753 | "history": [ 754 | { 755 | "timestamp": "2025-06-11T15:00:00.000Z", 756 | "action": "create_file", 757 | "details": {"filename": "test.js", "size": 1024} 758 | } 759 | ], 760 | "dontCrawlDirectory": false, 761 | "mcpContextUris": ["file:///workspace/docs", "https://api.example.com/docs"], 762 | "mcpServers": { 763 | "github": { 764 | "type": "stdio", 765 | "command": "npx", 766 | "args": ["-y", "@modelcontextprotocol/server-github"], 767 | "env": {"GITHUB_PERSONAL_ACCESS_TOKEN": "github_token_123"}, 768 | "autoApprove": ["search_repositories", "get_file_contents"], 769 | "disabled": false, 770 | "customConfig": {"rateLimit": 5000, "features": ["search", "read"]} 771 | }, 772 | "filesystem": { 773 | "type": "stdio", 774 | "command": "npx", 775 | "args": ["-y", "@modelcontextprotocol/server-filesystem", "/workspace"], 776 | "autoApprove": ["read_file", "list_directory"], 777 | "disabled": false, 778 | "permissions": {"read": true, "write": false, "execute": false} 779 | } 780 | }, 781 | "enabledMcpjsonServers": ["server1", "server2"], 782 | "disabledMcpjsonServers": ["server3", "server4"], 783 | "hasTrustDialogAccepted": true, 784 | "projectOnboardingSeenCount": 3, 785 | "customProjectSettings": { 786 | "linting": {"enabled": true, "rules": ["rule1", "rule2"]}, 787 | "formatting": {"tabSize": 2, "insertSpaces": true} 788 | } 789 | }, 790 | "/workspace/new-project": { 791 | "mcpServers": { 792 | "linear": { 793 | "type": "stdio", 794 | "command": "home/mcp-servers/linear-mcp-go", 795 | "args": ["serve", "--write-access=true"], 796 | "env": {"LINEAR_API_KEY": "test-api-key"}, 797 | "autoApprove": ["linear_get_issue", "linear_search_issues"], 798 | "disabled": false 799 | } 800 | } 801 | } 802 | }, 803 | "analytics": { 804 | "enabled": true, 805 | "sessionId": "session_12345", 806 | "metrics": {"commandsExecuted": 42, "filesModified": 15} 807 | }, 808 | "version": "1.2.3" 809 | }`, 810 | }, 811 | }, 812 | exitCode: 0, 813 | }, 814 | }, 815 | { 816 | name: "Claude Code Update Existing Linear Server", 817 | toolParam: "claude-code", 818 | projectPath: "/workspace/existing-project", 819 | writeAccess: false, 820 | autoApprove: "allow-read-only", 821 | preExistingFiles: map[string]preExistingFile{ 822 | "claude-code": { 823 | path: "home/.claude.json", 824 | content: `{ 825 | "projects": { 826 | "/workspace/existing-project": { 827 | "mcpServers": { 828 | "linear": { 829 | "type": "stdio", 830 | "command": "/old/path/to/linear", 831 | "args": ["serve", "--old-flag"], 832 | "env": {"LINEAR_API_KEY": "old-key"}, 833 | "autoApprove": ["old_tool"], 834 | "disabled": true 835 | }, 836 | "other-server": { 837 | "command": "/path/to/other/server" 838 | } 839 | } 840 | } 841 | } 842 | }`, 843 | }, 844 | }, 845 | expect: expectations{ 846 | files: map[string]fileExpectation{ 847 | "claude-code": { 848 | path: "home/.claude.json", 849 | mustExist: true, 850 | content: `{ 851 | "projects": { 852 | "/workspace/existing-project": { 853 | "mcpServers": { 854 | "linear": { 855 | "type": "stdio", 856 | "command": "home/mcp-servers/linear-mcp-go", 857 | "args": ["serve"], 858 | "env": {"LINEAR_API_KEY": "test-api-key"}, 859 | "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"], 860 | "disabled": false 861 | }, 862 | "other-server": { 863 | "command": "/path/to/other/server" 864 | } 865 | } 866 | } 867 | } 868 | }`, 869 | }, 870 | }, 871 | exitCode: 0, 872 | }, 873 | }, 874 | { 875 | name: "Claude Code User Scope with Existing User-Scoped Servers", 876 | toolParam: "claude-code", 877 | writeAccess: true, 878 | autoApprove: "linear_get_issue,linear_search_issues", 879 | preExistingFiles: map[string]preExistingFile{ 880 | "claude-code": { 881 | path: "home/.claude.json", 882 | content: `{ 883 | "projects": { 884 | "/workspace/project1": { 885 | "mcpServers": { 886 | "project-specific-server": { 887 | "command": "/path/to/project/server" 888 | } 889 | } 890 | } 891 | }, 892 | "mcpServers": { 893 | "existing-user-server": { 894 | "type": "stdio", 895 | "command": "/path/to/existing/user/server", 896 | "args": ["serve"], 897 | "env": {"API_KEY": "existing-key"}, 898 | "autoApprove": ["existing_tool"], 899 | "disabled": false 900 | } 901 | } 902 | }`, 903 | }, 904 | }, 905 | expect: expectations{ 906 | files: map[string]fileExpectation{ 907 | "claude-code": { 908 | path: "home/.claude.json", 909 | mustExist: true, 910 | content: `{ 911 | "projects": { 912 | "/workspace/project1": { 913 | "mcpServers": { 914 | "project-specific-server": { 915 | "command": "/path/to/project/server" 916 | } 917 | } 918 | } 919 | }, 920 | "mcpServers": { 921 | "existing-user-server": { 922 | "type": "stdio", 923 | "command": "/path/to/existing/user/server", 924 | "args": ["serve"], 925 | "env": {"API_KEY": "existing-key"}, 926 | "autoApprove": ["existing_tool"], 927 | "disabled": false 928 | }, 929 | "linear": { 930 | "type": "stdio", 931 | "command": "home/mcp-servers/linear-mcp-go", 932 | "args": ["serve", "--write-access=true"], 933 | "env": {"LINEAR_API_KEY": "test-api-key"}, 934 | "autoApprove": ["linear_get_issue", "linear_search_issues"], 935 | "disabled": false 936 | } 937 | } 938 | }`, 939 | }, 940 | }, 941 | exitCode: 0, 942 | }, 943 | }, 944 | { 945 | name: "Claude Code User Scope Update Existing User-Scoped Linear Server", 946 | toolParam: "claude-code", 947 | writeAccess: false, 948 | autoApprove: "allow-read-only", 949 | preExistingFiles: map[string]preExistingFile{ 950 | "claude-code": { 951 | path: "home/.claude.json", 952 | content: `{ 953 | "projects": {}, 954 | "mcpServers": { 955 | "linear": { 956 | "type": "stdio", 957 | "command": "/old/path/to/linear", 958 | "args": ["serve", "--old-flag"], 959 | "env": {"LINEAR_API_KEY": "old-key"}, 960 | "autoApprove": ["old_tool"], 961 | "disabled": true 962 | }, 963 | "other-user-server": { 964 | "command": "/path/to/other/user/server" 965 | } 966 | } 967 | }`, 968 | }, 969 | }, 970 | expect: expectations{ 971 | files: map[string]fileExpectation{ 972 | "claude-code": { 973 | path: "home/.claude.json", 974 | mustExist: true, 975 | content: `{ 976 | "projects": {}, 977 | "mcpServers": { 978 | "linear": { 979 | "type": "stdio", 980 | "command": "home/mcp-servers/linear-mcp-go", 981 | "args": ["serve"], 982 | "env": {"LINEAR_API_KEY": "test-api-key"}, 983 | "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"], 984 | "disabled": false 985 | }, 986 | "other-user-server": { 987 | "command": "/path/to/other/user/server" 988 | } 989 | } 990 | }`, 991 | }, 992 | }, 993 | exitCode: 0, 994 | }, 995 | }, 996 | { 997 | name: "Ona Only", 998 | toolParam: "ona", 999 | writeAccess: true, 1000 | expect: expectations{ 1001 | files: map[string]fileExpectation{ 1002 | "ona": { 1003 | path: ".ona/mcp-config.json", 1004 | mustExist: true, 1005 | content: `{ 1006 | "mcpServers": { 1007 | "linear": { 1008 | "command": "home/mcp-servers/linear-mcp-go", 1009 | "args": ["serve", "--write-access=true"], 1010 | "disabled": false 1011 | } 1012 | } 1013 | }`, 1014 | }, 1015 | }, 1016 | exitCode: 0, 1017 | }, 1018 | }, 1019 | { 1020 | name: "Ona with Project Path", 1021 | toolParam: "ona", 1022 | projectPath: "/workspace/test-project", 1023 | writeAccess: false, 1024 | expect: expectations{ 1025 | files: map[string]fileExpectation{ 1026 | "ona": { 1027 | path: "/workspace/test-project/.ona/mcp-config.json", 1028 | mustExist: true, 1029 | content: `{ 1030 | "mcpServers": { 1031 | "linear": { 1032 | "command": "home/mcp-servers/linear-mcp-go", 1033 | "args": ["serve"], 1034 | "disabled": false 1035 | } 1036 | } 1037 | }`, 1038 | }, 1039 | }, 1040 | exitCode: 0, 1041 | }, 1042 | }, 1043 | { 1044 | name: "Ona with Existing Config", 1045 | toolParam: "ona", 1046 | writeAccess: true, 1047 | preExistingFiles: map[string]preExistingFile{ 1048 | "ona": { 1049 | path: ".ona/mcp-config.json", 1050 | content: `{ 1051 | "mcpServers": { 1052 | "playwright": { 1053 | "name": "playwright", 1054 | "command": "npx", 1055 | "args": ["-y", "@executeautomation/playwright-mcp-server"] 1056 | } 1057 | } 1058 | }`, 1059 | }, 1060 | }, 1061 | expect: expectations{ 1062 | files: map[string]fileExpectation{ 1063 | "ona": { 1064 | path: ".ona/mcp-config.json", 1065 | mustExist: true, 1066 | content: `{ 1067 | "mcpServers": { 1068 | "playwright": { 1069 | "name": "playwright", 1070 | "command": "npx", 1071 | "args": ["-y", "@executeautomation/playwright-mcp-server"] 1072 | }, 1073 | "linear": { 1074 | "command": "home/mcp-servers/linear-mcp-go", 1075 | "args": ["serve", "--write-access=true"], 1076 | "disabled": false 1077 | } 1078 | } 1079 | }`, 1080 | }, 1081 | }, 1082 | exitCode: 0, 1083 | }, 1084 | }, 1085 | { 1086 | name: "Ona with Complex Nested Config", 1087 | toolParam: "ona", 1088 | writeAccess: false, 1089 | preExistingFiles: map[string]preExistingFile{ 1090 | "ona": { 1091 | path: ".ona/mcp-config.json", 1092 | content: `{ 1093 | "version": "1.0.0", 1094 | "metadata": { 1095 | "created": "2024-01-01T00:00:00Z", 1096 | "author": "test-user", 1097 | "tags": ["development", "testing", "automation"], 1098 | "config": { 1099 | "nested": { 1100 | "deeply": { 1101 | "properties": ["value1", "value2", "value3"], 1102 | "settings": { 1103 | "enabled": true, 1104 | "timeout": 30000, 1105 | "retries": 3, 1106 | "features": { 1107 | "advanced": { 1108 | "caching": true, 1109 | "compression": false, 1110 | "encryption": { 1111 | "algorithm": "AES-256", 1112 | "keySize": 256, 1113 | "modes": ["CBC", "GCM", "CTR"] 1114 | } 1115 | } 1116 | } 1117 | } 1118 | } 1119 | } 1120 | } 1121 | }, 1122 | "mcpServers": { 1123 | "custom-server": { 1124 | "name": "custom-server", 1125 | "command": "/usr/local/bin/custom-mcp-server", 1126 | "args": ["--mode", "production", "--verbose"], 1127 | "env": { 1128 | "CUSTOM_API_KEY": "secret-key-123", 1129 | "CUSTOM_ENDPOINT": "https://api.example.com/v1" 1130 | }, 1131 | "timeout": 60000, 1132 | "retries": 5, 1133 | "features": { 1134 | "streaming": true, 1135 | "batching": false, 1136 | "compression": { 1137 | "enabled": true, 1138 | "algorithm": "gzip", 1139 | "level": 6 1140 | } 1141 | }, 1142 | "customArrays": { 1143 | "supportedFormats": ["json", "xml", "yaml"], 1144 | "allowedOrigins": ["localhost", "*.example.com", "api.test.com"], 1145 | "permissions": ["read", "write", "execute"] 1146 | }, 1147 | "nestedConfig": { 1148 | "database": { 1149 | "connection": { 1150 | "host": "localhost", 1151 | "port": 5432, 1152 | "ssl": { 1153 | "enabled": true, 1154 | "cert": "/path/to/cert.pem", 1155 | "key": "/path/to/key.pem", 1156 | "ca": "/path/to/ca.pem" 1157 | } 1158 | }, 1159 | "pool": { 1160 | "min": 5, 1161 | "max": 20, 1162 | "idle": 300 1163 | } 1164 | } 1165 | } 1166 | } 1167 | }, 1168 | "globalSettings": { 1169 | "logLevel": "info", 1170 | "enableMetrics": true, 1171 | "metricsConfig": { 1172 | "endpoint": "http://metrics.example.com:9090", 1173 | "interval": 30, 1174 | "labels": { 1175 | "environment": "test", 1176 | "service": "mcp-server", 1177 | "version": "1.0.0" 1178 | } 1179 | } 1180 | } 1181 | }`, 1182 | }, 1183 | }, 1184 | expect: expectations{ 1185 | files: map[string]fileExpectation{ 1186 | "ona": { 1187 | path: ".ona/mcp-config.json", 1188 | mustExist: true, 1189 | content: `{ 1190 | "version": "1.0.0", 1191 | "metadata": { 1192 | "created": "2024-01-01T00:00:00Z", 1193 | "author": "test-user", 1194 | "tags": ["development", "testing", "automation"], 1195 | "config": { 1196 | "nested": { 1197 | "deeply": { 1198 | "properties": ["value1", "value2", "value3"], 1199 | "settings": { 1200 | "enabled": true, 1201 | "timeout": 30000, 1202 | "retries": 3, 1203 | "features": { 1204 | "advanced": { 1205 | "caching": true, 1206 | "compression": false, 1207 | "encryption": { 1208 | "algorithm": "AES-256", 1209 | "keySize": 256, 1210 | "modes": ["CBC", "GCM", "CTR"] 1211 | } 1212 | } 1213 | } 1214 | } 1215 | } 1216 | } 1217 | } 1218 | }, 1219 | "mcpServers": { 1220 | "custom-server": { 1221 | "name": "custom-server", 1222 | "command": "/usr/local/bin/custom-mcp-server", 1223 | "args": ["--mode", "production", "--verbose"], 1224 | "env": { 1225 | "CUSTOM_API_KEY": "secret-key-123", 1226 | "CUSTOM_ENDPOINT": "https://api.example.com/v1" 1227 | }, 1228 | "timeout": 60000, 1229 | "retries": 5, 1230 | "features": { 1231 | "streaming": true, 1232 | "batching": false, 1233 | "compression": { 1234 | "enabled": true, 1235 | "algorithm": "gzip", 1236 | "level": 6 1237 | } 1238 | }, 1239 | "customArrays": { 1240 | "supportedFormats": ["json", "xml", "yaml"], 1241 | "allowedOrigins": ["localhost", "*.example.com", "api.test.com"], 1242 | "permissions": ["read", "write", "execute"] 1243 | }, 1244 | "nestedConfig": { 1245 | "database": { 1246 | "connection": { 1247 | "host": "localhost", 1248 | "port": 5432, 1249 | "ssl": { 1250 | "enabled": true, 1251 | "cert": "/path/to/cert.pem", 1252 | "key": "/path/to/key.pem", 1253 | "ca": "/path/to/ca.pem" 1254 | } 1255 | }, 1256 | "pool": { 1257 | "min": 5, 1258 | "max": 20, 1259 | "idle": 300 1260 | } 1261 | } 1262 | } 1263 | }, 1264 | "linear": { 1265 | "command": "home/mcp-servers/linear-mcp-go", 1266 | "args": ["serve"], 1267 | "disabled": false 1268 | } 1269 | }, 1270 | "globalSettings": { 1271 | "logLevel": "info", 1272 | "enableMetrics": true, 1273 | "metricsConfig": { 1274 | "endpoint": "http://metrics.example.com:9090", 1275 | "interval": 30, 1276 | "labels": { 1277 | "environment": "test", 1278 | "service": "mcp-server", 1279 | "version": "1.0.0" 1280 | } 1281 | } 1282 | } 1283 | }`, 1284 | }, 1285 | }, 1286 | exitCode: 0, 1287 | }, 1288 | }, 1289 | 1290 | } 1291 | 1292 | // Run each test case 1293 | for _, tc := range testCases { 1294 | t.Run(tc.name, func(t *testing.T) { 1295 | // Create a temporary directory 1296 | rootDir, err := os.MkdirTemp("", "linear-mcp-go-test-*") 1297 | if err != nil { 1298 | t.Fatalf("Failed to create temp dir: %v", err) 1299 | } 1300 | defer os.RemoveAll(rootDir) 1301 | 1302 | // Set up the directory structure 1303 | homeDir := filepath.Join(rootDir, "home") 1304 | 1305 | // Copy the binary to the temp directory 1306 | tempBinaryPath := filepath.Join(rootDir, "linear-mcp-go") 1307 | if err := copyFile(binaryPath, tempBinaryPath); err != nil { 1308 | t.Fatalf("Failed to copy binary: %v", err) 1309 | } 1310 | if err := os.Chmod(tempBinaryPath, 0755); err != nil { 1311 | t.Fatalf("Failed to make binary executable: %v", err) 1312 | } 1313 | 1314 | // Set the HOME environment variable 1315 | oldHome := os.Getenv("HOME") 1316 | os.Setenv("HOME", homeDir) 1317 | defer os.Setenv("HOME", oldHome) 1318 | 1319 | // Set the LINEAR_API_KEY environment variable 1320 | oldApiKey := os.Getenv("LINEAR_API_KEY") 1321 | os.Setenv("LINEAR_API_KEY", "test-api-key") 1322 | defer os.Setenv("LINEAR_API_KEY", oldApiKey) 1323 | 1324 | // Create pre-existing files if specified 1325 | for _, preFile := range tc.preExistingFiles { 1326 | fullPath := filepath.Join(rootDir, preFile.path) 1327 | if err := os.MkdirAll(filepath.Dir(fullPath), 0755); err != nil { 1328 | t.Fatalf("Failed to create directory for pre-existing file %s: %v", fullPath, err) 1329 | } 1330 | if err := os.WriteFile(fullPath, []byte(preFile.content), 0644); err != nil { 1331 | t.Fatalf("Failed to create pre-existing file %s: %v", fullPath, err) 1332 | } 1333 | } 1334 | 1335 | // Build the command 1336 | args := []string{"setup", "--tool=" + tc.toolParam} 1337 | if tc.writeAccess { 1338 | args = append(args, "--write-access=true") 1339 | } 1340 | if tc.autoApprove != "" { 1341 | args = append(args, "--auto-approve="+tc.autoApprove) 1342 | } 1343 | if tc.projectPath != "" { 1344 | args = append(args, "--project-path="+tc.projectPath) 1345 | } 1346 | 1347 | // Execute the command 1348 | cmd := exec.Command(tempBinaryPath, args...) 1349 | cmd.Dir = rootDir // Set working directory to the test root 1350 | var stdout, stderr bytes.Buffer 1351 | cmd.Stdout = &stdout 1352 | cmd.Stderr = &stderr 1353 | err = cmd.Run() 1354 | 1355 | // Check exit code 1356 | exitCode := 0 1357 | if err != nil { 1358 | if exitError, ok := err.(*exec.ExitError); ok { 1359 | exitCode = exitError.ExitCode() 1360 | } else { 1361 | t.Fatalf("Failed to run command: %v", err) 1362 | } 1363 | } 1364 | 1365 | // Verify exit code 1366 | if exitCode != tc.expect.exitCode { 1367 | t.Errorf("Expected exit code %d, got %d", tc.expect.exitCode, exitCode) 1368 | } 1369 | 1370 | // Verify expected files 1371 | verifyFileExpectations(t, rootDir, tc.expect.files) 1372 | 1373 | // Verify expected errors in output 1374 | output := stdout.String() + stderr.String() 1375 | for _, expectedError := range tc.expect.errors { 1376 | if !strings.Contains(output, expectedError) { 1377 | t.Errorf("Expected output to contain '%s', got: %s", expectedError, output) 1378 | } 1379 | } 1380 | }) 1381 | } 1382 | } 1383 | 1384 | // Helper function to verify file expectations 1385 | func verifyFileExpectations(t *testing.T, rootDir string, fileExpects map[string]fileExpectation) { 1386 | for tool, expect := range fileExpects { 1387 | filePath := filepath.Join(rootDir, expect.path) 1388 | 1389 | // Check if file exists 1390 | _, err := os.Stat(filePath) 1391 | if os.IsNotExist(err) { 1392 | if expect.mustExist { 1393 | t.Errorf("Expected file %s was not created for %s", filePath, tool) 1394 | } 1395 | continue 1396 | } 1397 | 1398 | // File exists, verify content if expected 1399 | if expect.content != "" { 1400 | actualContent, err := os.ReadFile(filePath) 1401 | if err != nil { 1402 | t.Fatalf("Failed to read configuration file %s: %v", filePath, err) 1403 | } 1404 | 1405 | // Parse both expected and actual content as JSON for comparison 1406 | var expectedJSON, actualJSON map[string]interface{} 1407 | 1408 | if err := json.Unmarshal([]byte(expect.content), &expectedJSON); err != nil { 1409 | t.Fatalf("Failed to parse expected JSON for %s: %v", tool, err) 1410 | } 1411 | 1412 | if err := json.Unmarshal(actualContent, &actualJSON); err != nil { 1413 | t.Fatalf("Failed to parse actual JSON in file %s: %v", filePath, err) 1414 | } 1415 | 1416 | // Process the JSON objects to make them comparable 1417 | normalizeJSON(expectedJSON) 1418 | normalizeJSON(actualJSON) 1419 | 1420 | // Compare the JSON objects 1421 | if diff := cmp.Diff(expectedJSON, actualJSON); diff != "" { 1422 | t.Errorf("File content mismatch for %s (-want +got):\n%s", tool, diff) 1423 | } 1424 | } 1425 | } 1426 | } 1427 | 1428 | // normalizeJSON processes a JSON object to make it comparable 1429 | // by removing fields that may vary and sorting arrays 1430 | func normalizeJSON(jsonObj map[string]interface{}) { 1431 | normalizeCommandPaths(jsonObj) 1432 | normalizeJSONRecursive(jsonObj) 1433 | } 1434 | 1435 | // normalizeCommandPaths normalizes command paths in server configurations 1436 | func normalizeCommandPaths(obj interface{}) { 1437 | switch v := obj.(type) { 1438 | case map[string]interface{}: 1439 | // Look for server configuration containers 1440 | if isServerContainer(v) { 1441 | for _, serverConfig := range v { 1442 | if serverMap, ok := serverConfig.(map[string]interface{}); ok { 1443 | // Normalize the command field by stripping temporary directory prefix 1444 | if command, ok := serverMap["command"].(string); ok { 1445 | // Strip the temporary test directory prefix, keeping only the meaningful part 1446 | // Pattern: /tmp/linear-mcp-go-test-*/home/... -> home/... 1447 | if strings.Contains(command, "/home/") { 1448 | parts := strings.Split(command, "/home/") 1449 | if len(parts) > 1 { 1450 | serverMap["command"] = "home/" + parts[1] 1451 | } 1452 | } 1453 | } 1454 | } 1455 | } 1456 | } 1457 | 1458 | // Process nested objects recursively 1459 | for _, value := range v { 1460 | normalizeCommandPaths(value) 1461 | } 1462 | 1463 | case []interface{}: 1464 | // Process array elements recursively 1465 | for _, item := range v { 1466 | normalizeCommandPaths(item) 1467 | } 1468 | } 1469 | } 1470 | 1471 | // isServerContainer checks if a map contains server configurations 1472 | func isServerContainer(m map[string]interface{}) bool { 1473 | // Check if this looks like a server container by examining its values 1474 | for _, value := range m { 1475 | if serverMap, ok := value.(map[string]interface{}); ok { 1476 | // If it has command field, it's likely a server config container 1477 | if _, hasCommand := serverMap["command"]; hasCommand { 1478 | return true 1479 | } 1480 | } 1481 | } 1482 | return false 1483 | } 1484 | 1485 | // normalizeJSONRecursive recursively processes JSON objects to normalize them for comparison 1486 | func normalizeJSONRecursive(obj interface{}) { 1487 | switch v := obj.(type) { 1488 | case map[string]interface{}: 1489 | // Process all map entries recursively 1490 | for _, value := range v { 1491 | normalizeJSONRecursive(value) 1492 | } 1493 | 1494 | case []interface{}: 1495 | // Sort arrays if they contain strings 1496 | if len(v) > 0 { 1497 | // Check if all elements are strings 1498 | allStrings := true 1499 | for _, item := range v { 1500 | if _, ok := item.(string); !ok { 1501 | allStrings = false 1502 | break 1503 | } 1504 | } 1505 | 1506 | if allStrings { 1507 | // Convert to string slice, sort, and convert back 1508 | strSlice := make([]string, len(v)) 1509 | for i, item := range v { 1510 | strSlice[i] = item.(string) 1511 | } 1512 | sort.Strings(strSlice) 1513 | 1514 | // Update the original slice in place 1515 | for i, str := range strSlice { 1516 | v[i] = str 1517 | } 1518 | } 1519 | } 1520 | 1521 | // Process array elements recursively 1522 | for _, item := range v { 1523 | normalizeJSONRecursive(item) 1524 | } 1525 | } 1526 | } 1527 | 1528 | // Helper function to build the binary 1529 | func buildBinary() (string, error) { 1530 | // Create a temporary directory for the binary 1531 | tempDir, err := os.MkdirTemp("", "linear-mcp-go-build-*") 1532 | if err != nil { 1533 | return "", fmt.Errorf("failed to create temp dir: %w", err) 1534 | } 1535 | 1536 | // Get the project root directory (parent of cmd directory) 1537 | currentDir, err := os.Getwd() 1538 | if err != nil { 1539 | os.RemoveAll(tempDir) 1540 | return "", fmt.Errorf("failed to get current directory: %w", err) 1541 | } 1542 | 1543 | // Ensure we're building from the project root 1544 | projectRoot := filepath.Dir(currentDir) 1545 | if filepath.Base(currentDir) != "cmd" { 1546 | // If we're already in the project root, use the current directory 1547 | projectRoot = currentDir 1548 | } 1549 | 1550 | fmt.Printf("Building binary from project root: %s\n", projectRoot) 1551 | 1552 | // Build the binary 1553 | binaryPath := filepath.Join(tempDir, "linear-mcp-go") 1554 | cmd := exec.Command("go", "build", "-o", binaryPath) 1555 | cmd.Dir = projectRoot // Set the working directory to the project root 1556 | 1557 | var stdout, stderr bytes.Buffer 1558 | cmd.Stdout = &stdout 1559 | cmd.Stderr = &stderr 1560 | 1561 | if err := cmd.Run(); err != nil { 1562 | os.RemoveAll(tempDir) 1563 | return "", fmt.Errorf("failed to build binary: %w\nstdout: %s\nstderr: %s", 1564 | err, stdout.String(), stderr.String()) 1565 | } 1566 | 1567 | // Verify the binary exists and is executable 1568 | info, err := os.Stat(binaryPath) 1569 | if err != nil { 1570 | os.RemoveAll(tempDir) 1571 | return "", fmt.Errorf("failed to stat binary: %w", err) 1572 | } 1573 | 1574 | if info.Size() == 0 { 1575 | os.RemoveAll(tempDir) 1576 | return "", fmt.Errorf("binary file is empty") 1577 | } 1578 | 1579 | // Make sure the binary is executable 1580 | if err := os.Chmod(binaryPath, 0755); err != nil { 1581 | os.RemoveAll(tempDir) 1582 | return "", fmt.Errorf("failed to make binary executable: %w", err) 1583 | } 1584 | 1585 | fmt.Printf("Successfully built binary at %s (size: %d bytes)\n", binaryPath, info.Size()) 1586 | return binaryPath, nil 1587 | } 1588 | 1589 | // Helper function to copy a file 1590 | func copyFile(src, dst string) error { 1591 | sourceFile, err := os.Open(src) 1592 | if err != nil { 1593 | return fmt.Errorf("failed to open source file: %w", err) 1594 | } 1595 | defer sourceFile.Close() 1596 | 1597 | destFile, err := os.Create(dst) 1598 | if err != nil { 1599 | return fmt.Errorf("failed to create destination file: %w", err) 1600 | } 1601 | defer destFile.Close() 1602 | 1603 | if _, err := io.Copy(destFile, sourceFile); err != nil { 1604 | return fmt.Errorf("failed to copy file: %w", err) 1605 | } 1606 | 1607 | return nil 1608 | } 1609 | 1610 | // TestOnaNewlinePreservation specifically tests that the Ona setup preserves newlines and empty lines 1611 | func TestOnaNewlinePreservation(t *testing.T) { 1612 | // Build the binary 1613 | binaryPath, err := buildBinary() 1614 | if err != nil { 1615 | t.Fatalf("Failed to build binary: %v", err) 1616 | } 1617 | defer os.RemoveAll(filepath.Dir(binaryPath)) 1618 | 1619 | // Create a temporary directory 1620 | rootDir, err := os.MkdirTemp("", "linear-mcp-go-newline-test-*") 1621 | if err != nil { 1622 | t.Fatalf("Failed to create temp dir: %v", err) 1623 | } 1624 | defer os.RemoveAll(rootDir) 1625 | 1626 | // Set up the directory structure 1627 | homeDir := filepath.Join(rootDir, "home") 1628 | configDir := filepath.Join(rootDir, ".ona") 1629 | configPath := filepath.Join(configDir, "mcp-config.json") 1630 | 1631 | // Create the config directory 1632 | if err := os.MkdirAll(configDir, 0755); err != nil { 1633 | t.Fatalf("Failed to create config directory: %v", err) 1634 | } 1635 | 1636 | // Create pre-existing config with trailing newlines and empty lines 1637 | originalContent := `{ 1638 | "mcpServers": { 1639 | "playwright": { 1640 | "name": "playwright", 1641 | "command": "npx", 1642 | "args": ["-y", "@executeautomation/playwright-mcp-server"] 1643 | } 1644 | } 1645 | } 1646 | 1647 | 1648 | ` 1649 | if err := os.WriteFile(configPath, []byte(originalContent), 0644); err != nil { 1650 | t.Fatalf("Failed to create pre-existing config: %v", err) 1651 | } 1652 | 1653 | // Copy the binary to the temp directory 1654 | tempBinaryPath := filepath.Join(rootDir, "linear-mcp-go") 1655 | if err := copyFile(binaryPath, tempBinaryPath); err != nil { 1656 | t.Fatalf("Failed to copy binary: %v", err) 1657 | } 1658 | if err := os.Chmod(tempBinaryPath, 0755); err != nil { 1659 | t.Fatalf("Failed to make binary executable: %v", err) 1660 | } 1661 | 1662 | // Set environment variables 1663 | oldHome := os.Getenv("HOME") 1664 | os.Setenv("HOME", homeDir) 1665 | defer os.Setenv("HOME", oldHome) 1666 | 1667 | oldApiKey := os.Getenv("LINEAR_API_KEY") 1668 | os.Setenv("LINEAR_API_KEY", "test-api-key") 1669 | defer os.Setenv("LINEAR_API_KEY", oldApiKey) 1670 | 1671 | // Execute the setup command 1672 | cmd := exec.Command(tempBinaryPath, "setup", "--tool=ona", "--write-access=true") 1673 | cmd.Dir = rootDir 1674 | var stdout, stderr bytes.Buffer 1675 | cmd.Stdout = &stdout 1676 | cmd.Stderr = &stderr 1677 | err = cmd.Run() 1678 | 1679 | if err != nil { 1680 | t.Fatalf("Setup command failed: %v\nStdout: %s\nStderr: %s", err, stdout.String(), stderr.String()) 1681 | } 1682 | 1683 | // Read the updated config file 1684 | updatedContent, err := os.ReadFile(configPath) 1685 | if err != nil { 1686 | t.Fatalf("Failed to read updated config: %v", err) 1687 | } 1688 | 1689 | // Check if the trailing newlines are preserved 1690 | updatedStr := string(updatedContent) 1691 | if !strings.HasSuffix(updatedStr, "\n\n\n") { 1692 | t.Errorf("Expected config to end with three newlines, but got:\n%q", updatedStr[len(updatedStr)-10:]) 1693 | t.Errorf("Full updated content:\n%q", updatedStr) 1694 | } 1695 | 1696 | // Verify the JSON is still valid 1697 | var config map[string]interface{} 1698 | if err := json.Unmarshal(updatedContent, &config); err != nil { 1699 | t.Fatalf("Updated config is not valid JSON: %v", err) 1700 | } 1701 | 1702 | // Verify the linear server was added 1703 | mcpServers, ok := config["mcpServers"].(map[string]interface{}) 1704 | if !ok { 1705 | t.Fatalf("mcpServers not found in config") 1706 | } 1707 | 1708 | linear, ok := mcpServers["linear"].(map[string]interface{}) 1709 | if !ok { 1710 | t.Fatalf("linear server not found in mcpServers") 1711 | } 1712 | 1713 | // Verify linear server configuration 1714 | if linear["disabled"] != false { 1715 | t.Errorf("Expected linear server to be enabled") 1716 | } 1717 | 1718 | args, ok := linear["args"].([]interface{}) 1719 | if !ok || len(args) != 2 || args[0] != "serve" || args[1] != "--write-access=true" { 1720 | t.Errorf("Expected linear server args to be [\"serve\", \"--write-access=true\"], got %v", args) 1721 | } 1722 | } 1723 | ```