#
tokens: 45560/50000 5/150 files (page 5/6)
lines: on (toggle) GitHub
raw markdown copy reset
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 | 
```
Page 5/6FirstPrevNextLast