This is page 1 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 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- ``` 1 | linear-mcp-go 2 | .context ``` -------------------------------------------------------------------------------- /.gitpod.yml: -------------------------------------------------------------------------------- ```yaml 1 | 2 | tasks: 3 | 4 | - name: Build and test the Go project 5 | init: | 6 | go build ./... && go test ./... 7 | 8 | - name: Setup Linear MCP Server and Cline 9 | init: | 10 | # Install Linear MCP Server and register with Linear Cline 11 | # Note: make sure to set LINEAR_API_KEY in the Gitpod environment variables for the MCP server to work 12 | ./scripts/register-cline.sh 13 | 14 | # Additional Gitpod configuration 15 | ports: 16 | - port: 3000-8000 17 | onOpen: ignore 18 | 19 | vscode: 20 | extensions: 21 | - golang.go 22 | ``` -------------------------------------------------------------------------------- /docs/prd/README.md: -------------------------------------------------------------------------------- ```markdown 1 | # Linear MCP Server PRD Documentation 2 | 3 | This directory contains Product Requirements Documents (PRDs) for the Linear MCP Server project. 4 | 5 | ## Available Documents 6 | 7 | | Document | Description | 8 | |----------|-------------| 9 | | [000-tool-standardization-overview.md](./000-tool-standardization-overview.md) | Executive summary and overview of the tool standardization effort | 10 | | [001-api-refresher.md](./001-api-refresher.md) | Documentation on the Linear API integration | 11 | | [002-tool-standardization.md](./002-tool-standardization.md) | Detailed requirements for tool standardization | 12 | | [003-tool-standardization-implementation.md](./003-tool-standardization-implementation.md) | Implementation guide for tool standardization | 13 | | [004-tool-standardization-tracking.md](./004-tool-standardization-tracking.md) | Tracking sheet for implementation progress | 14 | | [005-sample-implementation.md](./005-sample-implementation.md) | Sample code and implementation examples | 15 | 16 | ## Tool Standardization Series 17 | 18 | The tool standardization documents (000, 002, 003, 004, 005) form a series that outlines the requirements, implementation plan, and tracking for standardizing the Linear MCP Server tools according to a set of consistent rules: 19 | 20 | 1. **Rule 1: Concise Tool Descriptions** 21 | - Tool descriptions should be concise and focus only on the tool's purpose and functionality 22 | 23 | 2. **Rule 2: Flexible Object Identifier Resolution** 24 | - Input arguments that reference Linear objects should handle multiple values that identify the object 25 | 26 | 3. **Rule 3: Consistent Entity Rendering** 27 | - Tools fetching the same entities should emit results using the same format 28 | 29 | ## How to Use This Documentation 30 | 31 | 1. Start with [000-tool-standardization-overview.md](./000-tool-standardization-overview.md) for a high-level overview 32 | 2. Read [002-tool-standardization.md](./002-tool-standardization.md) for detailed requirements 33 | 3. Refer to [003-tool-standardization-implementation.md](./003-tool-standardization-implementation.md) for implementation details 34 | 4. Use [004-tool-standardization-tracking.md](./004-tool-standardization-tracking.md) to track progress 35 | 5. See [005-sample-implementation.md](./005-sample-implementation.md) for code examples 36 | 37 | ## Contributing 38 | 39 | When adding new PRDs to this directory, follow these guidelines: 40 | 41 | 1. Use a three-digit prefix (e.g., 006-) to ensure proper ordering 42 | 2. Include a clear title that describes the document's purpose 43 | 3. Link to related documents when appropriate 44 | 4. Update this README.md file to include the new document 45 | ``` -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- ```markdown 1 | # Linear MCP Server 2 | 3 | A Model Context Protocol (MCP) server for Linear, written in Go. This server provides tools for interacting with the Linear API through the MCP protocol. 4 | 5 | ## Features 6 | 7 | - Create, update, and search Linear issues 8 | - Get issues assigned to a user 9 | - Add comments to issues and reply to existing comments 10 | - **URL-aware comment operations** - paste Linear comment URLs directly, no manual ID extraction needed 11 | - Retrieve team information 12 | - Rate-limited API requests to respect Linear's API limits 13 | 14 | ## Prerequisites 15 | 16 | - Go 1.23 or higher 17 | - Linear API key 18 | 19 | ## Installation 20 | 21 | ### From Releases 22 | 23 | Pre-built binaries are available for Linux, macOS, and Windows on the [GitHub Releases page](https://github.com/geropl/linear-mcp-go/releases). 24 | 25 | 1. Download the appropriate binary for your platform 26 | 2. Make it executable (Linux/macOS): 27 | 28 | ```bash 29 | chmod +x linear-mcp-go-* 30 | ``` 31 | 32 | 3. Run the binary as described in the Usage section 33 | 34 | ### Automated 35 | 36 | ``` 37 | # Download linux binary for the latest release 38 | RELEASE=$(curl -s https://api.github.com/repos/geropl/linear-mcp-go/releases/latest) 39 | DOWNLOAD_URL=$(echo $RELEASE | jq -r '.assets[] | select(.name | contains("linux")) | .browser_download_url') 40 | curl -L -o ./linear-mcp-go $DOWNLOAD_URL 41 | chmod +x ./linear-mcp-go 42 | 43 | # Setup the mcp server (.gitpod.yml, dotfiles repo, etc.) 44 | ./linear-mcp-go setup --tool=cline 45 | ``` 46 | 47 | ## Usage 48 | 49 | ### Checking Version 50 | 51 | To check the version of the Linear MCP server: 52 | 53 | ```bash 54 | ./linear-mcp-go version 55 | ``` 56 | 57 | This will display the version, git commit, and build date information. 58 | 59 | ### Running the Server 60 | 61 | 1. Set your Linear API key as an environment variable: 62 | 63 | ```bash 64 | export LINEAR_API_KEY=your_linear_api_key 65 | ``` 66 | 67 | 2. Run the server: 68 | 69 | ```bash 70 | # Run in read-only mode (default) 71 | ./linear-mcp-go serve 72 | 73 | # Run with write access enabled 74 | ./linear-mcp-go serve --write-access 75 | ``` 76 | 77 | The server will start and listen for MCP requests on stdin/stdout. 78 | 79 | ### Setting Up for AI Assistants 80 | 81 | The `setup` command automates the installation and configuration process for various AI assistants: 82 | 83 | ```bash 84 | # Set your Linear API key as an environment variable 85 | # Only exception: Ona does not require this for setup! 86 | export LINEAR_API_KEY=your_linear_api_key 87 | 88 | # Set up for Cline (default) 89 | ./linear-mcp-go setup 90 | 91 | # Set up with write access enabled 92 | ./linear-mcp-go setup --write-access 93 | 94 | # Set up with auto-approval for read-only tools 95 | ./linear-mcp-go setup --auto-approve=allow-read-only 96 | 97 | # Set up with specific tools auto-approved 98 | ./linear-mcp-go setup --auto-approve=linear_get_issue,linear_search_issues 99 | 100 | # Set up with write access and auto-approval for read-only tools 101 | ./linear-mcp-go setup --write-access --auto-approve=allow-read-only 102 | 103 | # Set up for a different tool (only "cline" supported for now) 104 | ./linear-mcp-go setup --tool=cline 105 | ``` 106 | 107 | This command: 108 | 1. Checks if the Linear MCP binary is already installed 109 | 2. Copies the current binary to the installation directory if needed 110 | 3. Configures the AI assistant to use the Linear MCP server 111 | 4. Sets up auto-approval for specified tools if requested 112 | 113 | The `--auto-approve` flag can be used to specify which tools should be auto-approved in the Cline configuration: 114 | - `--auto-approve=allow-read-only`: Auto-approves all read-only tools (`linear_search_issues`, `linear_get_user_issues`, `linear_get_issue`, `linear_get_teams`) 115 | - `--auto-approve=tool1,tool2,...`: Auto-approves the specified comma-separated list of tools 116 | 117 | Currently supported AI assistants: 118 | - Cline (VSCode extension) 119 | 120 | By default, the server runs in read-only mode, which means the following tools are disabled: 121 | - `linear_create_issue` 122 | - `linear_update_issue` 123 | - `linear_add_comment` 124 | - `linear_reply_to_comment` 125 | - `linear_update_issue_comment` 126 | 127 | To enable these tools, use the `--write-access=true` flag. 128 | 129 | ## Available Tools 130 | 131 | ### linear_create_issue 132 | 133 | Creates a new Linear issue with specified details. **Supports creating parent-child relationships** (sub-issues) and assigning labels. 134 | 135 | **Parameters:** 136 | - `title` (required): Issue title 137 | - `team` (required): Team identifier (key, UUID or name) 138 | - `description`: Issue description 139 | - `priority`: Priority. Accepts: 0/'no priority', 1/'urgent', 2/'high', 3/'medium', 4/'low' 140 | - `status`: Issue status 141 | - `makeSubissueOf`: **Create a sub-issue by specifying the parent issue ID or identifier** (e.g., 'TEAM-123'). This establishes a parent-child relationship in Linear. 142 | - `labels`: Optional comma-separated list of label IDs or names to assign 143 | - `project`: Optional project identifier (ID, name, or slug) to assign the issue to 144 | 145 | **Example: Creating a sub-issue** 146 | ```json 147 | { 148 | "title": "Implement login form validation", 149 | "team": "ENG", 150 | "makeSubissueOf": "ENG-42", 151 | "description": "Add client-side validation for the login form" 152 | } 153 | ``` 154 | 155 | ### linear_update_issue 156 | 157 | Updates an existing Linear issue's properties. 158 | 159 | **Parameters:** 160 | - `id` (required): Issue ID 161 | - `title`: New title 162 | - `description`: New description 163 | - `priority`: Priority. Accepts: 0/'no priority', 1/'urgent', 2/'high', 3/'medium', 4/'low' 164 | - `status`: New status 165 | 166 | ### linear_search_issues 167 | 168 | Searches Linear issues using flexible criteria. 169 | 170 | **Parameters:** 171 | - `query`: Optional text to search in title and description 172 | - `teamId`: Filter by team ID 173 | - `status`: Filter by status name (e.g., 'In Progress', 'Done') 174 | - `assigneeId`: Filter by assignee's user ID 175 | - `labels`: Filter by label names (comma-separated) 176 | - `priority`: Priority. Accepts: 0/'no priority', 1/'urgent', 2/'high', 3/'medium', 4/'low' 177 | - `estimate`: Filter by estimate points 178 | - `includeArchived`: Include archived issues in results (default: false) 179 | - `limit`: Max results to return (default: 10) 180 | 181 | ### linear_get_user_issues 182 | 183 | Retrieves issues assigned to a specific user or the authenticated user. 184 | 185 | **Parameters:** 186 | - `userId`: Optional user ID. If not provided, returns authenticated user's issues 187 | - `includeArchived`: Include archived issues in results 188 | - `limit`: Maximum number of issues to return (default: 50) 189 | 190 | ### linear_get_issue 191 | 192 | Retrieves a single Linear issue by its ID. 193 | 194 | **Parameters:** 195 | - `issueId` (required): ID of the issue to retrieve 196 | 197 | ### linear_add_comment 198 | 199 | Adds a comment to an existing Linear issue. Supports replying to existing comments by passing a comment identifier in the `thread` parameter. 200 | 201 | **Parameters:** 202 | - `issue` (required): ID or identifier (e.g., 'TEAM-123') of the issue to comment on 203 | - `body` (required): Comment text in markdown format 204 | - `thread`: Optional comment identifier to reply to. Accepts: full Linear comment URL, UUID, shorthand (comment-abc123), or hash (abc123). Creates a threaded reply instead of a top-level comment. 205 | - `createAsUser`: Optional custom username to show for the comment 206 | 207 | **URL Support:** You can pass a full Linear comment URL (e.g., `https://linear.app/.../issue/TEST-10/...#comment-abc123`) directly to the `thread` parameter. The tool automatically resolves URLs to UUIDs before calling the API. 208 | 209 | ### linear_reply_to_comment 210 | 211 | Convenience tool for replying to an existing comment. Automatically resolves the issue from the comment, so you only need to provide the comment identifier and reply text. 212 | 213 | **Parameters:** 214 | - `thread` (required): Comment to reply to. Accepts: full Linear comment URL, UUID, shorthand (comment-abc123), or hash (abc123) 215 | - `body` (required): Reply text in markdown format 216 | - `createAsUser`: Optional custom username to show for the reply 217 | 218 | **Why use this tool?** When you have a comment URL or ID and want to reply, this tool is simpler than `linear_add_comment` because you don't need to specify the issue separately. The tool automatically looks up the issue from the comment. 219 | 220 | ### linear_get_issue_comments 221 | 222 | Retrieves comments for a Linear issue with support for pagination and thread navigation. 223 | 224 | **Parameters:** 225 | - `issue` (required): ID or identifier (e.g., 'TEAM-123') of the issue to retrieve comments for 226 | - `thread`: Optional UUID of a parent comment to retrieve its replies. If not provided, returns top-level comments 227 | - `limit`: Maximum number of comments to return (default: 10) 228 | - `after`: Cursor for pagination, to get comments after this point 229 | 230 | **Use Cases:** 231 | - View all comments on an issue 232 | - Navigate comment threads by passing a comment UUID in the `thread` parameter 233 | - Get comment UUIDs for replying (though with URL support in `linear_add_comment`, this is less necessary) 234 | 235 | ### linear_update_issue_comment 236 | 237 | Updates an existing comment on a Linear issue. 238 | 239 | **Parameters:** 240 | - `comment` (required): Comment identifier to update. Accepts: full Linear comment URL, UUID, shorthand (comment-abc123), or hash (abc123) 241 | - `body` (required): New comment text in markdown format 242 | 243 | **URL Support:** Like other comment tools, this accepts full Linear comment URLs and automatically resolves them to UUIDs. 244 | 245 | ### linear_get_teams 246 | 247 | Retrieves Linear teams with an optional name filter. 248 | 249 | **Parameters:** 250 | - `name`: Optional team name filter. Returns teams whose names contain this string. 251 | 252 | ## Test 253 | Tests are implemented using [`go-vcr`](https://github.com/dnaeon/go-vcr), and executed against https://linear.app/linear-mcp-go-test. 254 | 255 | ### Execute tests 256 | 257 | Using the existing recordings (cassettes): 258 | ``` 259 | go test -v ./... 260 | ``` 261 | 262 | #### Re-recording test: 263 | 264 | Requires `TEST_LINEAR_API_KEY` to be set for the test workspace. 265 | 266 | ``` 267 | go test -v -record=true ./... 268 | ``` 269 | This will update all tests that don't alter remote state. 270 | 271 | 272 | ``` 273 | go test -v -recordWrites=true ./... 274 | ``` 275 | This will re-run all tests, including some that might alter the outcome of other tests cases, which might require further manual work to adjust. 276 | 277 | ``` 278 | go test -v -golden=true ./... 279 | ``` 280 | Updates all .golden fields. 281 | 282 | ## Release Process 283 | 284 | The project uses GitHub Actions for automated testing and releases. The version is managed through the `ServerVersion` constant in `pkg/server/server.go`. 285 | 286 | ### Automated Testing and Building 287 | 288 | 1. All pushes to the main branch and pull requests are automatically tested 289 | 2. When a tag matching the pattern `v*` (e.g., `v1.0.0`) is pushed, a new release is automatically created 290 | 3. Binaries for Linux, macOS, and Windows are built and attached to the release with build-time information (git commit and build date) 291 | 292 | ### Creating a New Release 293 | 294 | **Important**: Version tags should only be created against the `main` branch after all changes have been merged. 295 | 296 | 1. **Update the version**: Modify the `ServerVersion` constant in `pkg/server/server.go` 297 | ```go 298 | // ServerVersion is the version of the MCP server 299 | ServerVersion = "1.13.0" 300 | ``` 301 | 302 | 2. **Create a PR**: Submit the version update as a pull request to ensure it goes through review and testing 303 | 304 | 3. **Merge to main**: Once the PR is approved and merged to the main branch 305 | 306 | 4. **Create and push the release tag**: 307 | ```bash 308 | # Ensure you're on the latest main branch 309 | git checkout main 310 | git pull origin main 311 | 312 | # Create and push the tag (must match the version in server.go) 313 | git tag v1.13.0 314 | git push origin v1.13.0 315 | ``` 316 | 317 | 5. **Automated release**: The GitHub Actions workflow will automatically: 318 | - Build binaries for all platforms with proper version information 319 | - Create a GitHub release with the tag 320 | - Attach the compiled binaries to the release 321 | 322 | ### Version Information 323 | 324 | The `version` command displays: 325 | - **Version**: Read from `ServerVersion` constant in `pkg/server/server.go` 326 | - **Git commit**: Injected at build time from the current commit hash 327 | - **Build date**: Injected at build time with the current timestamp 328 | 329 | For development builds, git commit and build date will show "unknown". 330 | 331 | ## License 332 | 333 | MIT 334 | ``` -------------------------------------------------------------------------------- /testdata/fixtures/add_comment_handler_Missing issue.yaml: -------------------------------------------------------------------------------- ```yaml 1 | --- 2 | version: 2 3 | interactions: [] 4 | ``` -------------------------------------------------------------------------------- /testdata/fixtures/add_comment_handler_Missing issueId.yaml: -------------------------------------------------------------------------------- ```yaml 1 | --- 2 | version: 2 3 | interactions: [] 4 | ``` -------------------------------------------------------------------------------- /testdata/fixtures/create_initiative_handler_Missing name.yaml: -------------------------------------------------------------------------------- ```yaml 1 | --- 2 | version: 2 3 | interactions: [] 4 | ``` -------------------------------------------------------------------------------- /testdata/fixtures/create_issue_handler_Missing team.yaml: -------------------------------------------------------------------------------- ```yaml 1 | --- 2 | version: 2 3 | interactions: [] 4 | ``` -------------------------------------------------------------------------------- /testdata/fixtures/create_issue_handler_Missing teamId.yaml: -------------------------------------------------------------------------------- ```yaml 1 | --- 2 | version: 2 3 | interactions: [] 4 | ``` -------------------------------------------------------------------------------- /testdata/fixtures/create_issue_handler_Missing title.yaml: -------------------------------------------------------------------------------- ```yaml 1 | --- 2 | version: 2 3 | interactions: [] 4 | ``` -------------------------------------------------------------------------------- /testdata/fixtures/create_milestone_handler_Missing name.yaml: -------------------------------------------------------------------------------- ```yaml 1 | --- 2 | version: 2 3 | interactions: [] 4 | ``` -------------------------------------------------------------------------------- /testdata/fixtures/create_project_handler_Missing name.yaml: -------------------------------------------------------------------------------- ```yaml 1 | --- 2 | version: 2 3 | interactions: [] 4 | ``` -------------------------------------------------------------------------------- /testdata/fixtures/get_issue_comments_handler_Missing issue.yaml: -------------------------------------------------------------------------------- ```yaml 1 | --- 2 | version: 2 3 | interactions: [] 4 | ``` -------------------------------------------------------------------------------- /testdata/fixtures/get_issue_handler_Missing issue.yaml: -------------------------------------------------------------------------------- ```yaml 1 | --- 2 | version: 2 3 | interactions: [] 4 | ``` -------------------------------------------------------------------------------- /testdata/fixtures/get_project_handler_Missing project param.yaml: -------------------------------------------------------------------------------- ```yaml 1 | --- 2 | version: 2 3 | interactions: [] 4 | ``` -------------------------------------------------------------------------------- /testdata/fixtures/reply_to_comment_handler_Missing body.yaml: -------------------------------------------------------------------------------- ```yaml 1 | --- 2 | version: 2 3 | interactions: [] 4 | ``` -------------------------------------------------------------------------------- /testdata/fixtures/reply_to_comment_handler_Missing thread.yaml: -------------------------------------------------------------------------------- ```yaml 1 | --- 2 | version: 2 3 | interactions: [] 4 | ``` -------------------------------------------------------------------------------- /testdata/fixtures/resource_TeamResourceHandler_Missing ID.yaml: -------------------------------------------------------------------------------- ```yaml 1 | --- 2 | version: 2 3 | interactions: [] 4 | ``` -------------------------------------------------------------------------------- /testdata/fixtures/search_projects_handler_Empty query.yaml: -------------------------------------------------------------------------------- ```yaml 1 | --- 2 | version: 2 3 | interactions: [] 4 | ``` -------------------------------------------------------------------------------- /testdata/fixtures/update_comment_handler_Missing body.yaml: -------------------------------------------------------------------------------- ```yaml 1 | --- 2 | version: 2 3 | interactions: [] 4 | ``` -------------------------------------------------------------------------------- /testdata/fixtures/update_comment_handler_Missing comment.yaml: -------------------------------------------------------------------------------- ```yaml 1 | --- 2 | version: 2 3 | interactions: [] 4 | ``` -------------------------------------------------------------------------------- /testdata/fixtures/update_issue_handler_Missing id.yaml: -------------------------------------------------------------------------------- ```yaml 1 | --- 2 | version: 2 3 | interactions: [] 4 | ``` -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- ```go 1 | package main 2 | 3 | import "github.com/geropl/linear-mcp-go/cmd" 4 | 5 | func main() { 6 | cmd.Execute() 7 | } 8 | ``` -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- ```json 1 | { 2 | "name": "Gitpod", 3 | "build": { 4 | "context": ".", 5 | "dockerfile": "Dockerfile" 6 | }, 7 | "features": { 8 | "ghcr.io/devcontainers/features/go:1": {} 9 | } 10 | } 11 | ``` -------------------------------------------------------------------------------- /.devcontainer/Dockerfile: -------------------------------------------------------------------------------- ```dockerfile 1 | FROM mcr.microsoft.com/devcontainers/base:ubuntu-24.04 2 | 3 | # use this Dockerfile to install additional tools you might need, e.g. 4 | # RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ 5 | # && apt-get -y install --no-install-recommends <your-package-list-here> 6 | ``` -------------------------------------------------------------------------------- /cmd/version.go: -------------------------------------------------------------------------------- ```go 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/geropl/linear-mcp-go/pkg/server" 7 | "github.com/spf13/cobra" 8 | ) 9 | 10 | // Build information - these will be set at build time 11 | var ( 12 | GitCommit = "unknown" 13 | BuildDate = "unknown" 14 | ) 15 | 16 | // versionCmd represents the version command 17 | var versionCmd = &cobra.Command{ 18 | Use: "version", 19 | Short: "Print the version information", 20 | Long: `Print the version information for the Linear MCP server.`, 21 | Run: func(cmd *cobra.Command, args []string) { 22 | fmt.Printf("Linear MCP Server %s\n", server.ServerVersion) 23 | fmt.Printf("Git commit: %s\n", GitCommit) 24 | fmt.Printf("Build date: %s\n", BuildDate) 25 | }, 26 | } 27 | 28 | func init() { 29 | rootCmd.AddCommand(versionCmd) 30 | } ``` -------------------------------------------------------------------------------- /cmd/root.go: -------------------------------------------------------------------------------- ```go 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/spf13/cobra" 8 | ) 9 | 10 | // rootCmd represents the base command when called without any subcommands 11 | var rootCmd = &cobra.Command{ 12 | Use: "linear-mcp-go", 13 | Short: "Linear MCP Server - A Model Context Protocol server for Linear", 14 | Long: `Linear MCP Server is a Model Context Protocol (MCP) server for Linear. 15 | It provides tools for interacting with the Linear API through the MCP protocol, 16 | enabling AI assistants to manage Linear issues and workflows.`, 17 | } 18 | 19 | // Execute adds all child commands to the root command and sets flags appropriately. 20 | // This is called by main.main(). It only needs to happen once to the rootCmd. 21 | func Execute() { 22 | if err := rootCmd.Execute(); err != nil { 23 | fmt.Println(err) 24 | os.Exit(1) 25 | } 26 | } ``` -------------------------------------------------------------------------------- /memory-bank/projectbrief.md: -------------------------------------------------------------------------------- ```markdown 1 | # Project Brief: Linear MCP Server 2 | 3 | ## Overview 4 | Linear MCP Server is a Model Context Protocol (MCP) server for Linear, written in Go. It provides tools for interacting with the Linear API through the MCP protocol, enabling AI assistants to manage Linear issues and workflows. 5 | 6 | ## Core Requirements 7 | 1. Provide MCP tools for Linear API operations 8 | 2. Handle authentication via Linear API key 9 | 3. Support issue creation, updating, and searching 10 | 4. Enable comment addition to issues 11 | 5. Implement rate limiting to respect Linear's API limits 12 | 6. Ensure proper error handling and user feedback 13 | 14 | ## Goals 15 | - Create a reliable interface between AI assistants and Linear 16 | - Simplify Linear operations through standardized MCP tools 17 | - Maintain compatibility with the MCP protocol specification 18 | - Provide comprehensive documentation for users 19 | 20 | ## Project Scope 21 | - **In Scope**: Linear API integration, MCP server implementation, basic error handling, rate limiting 22 | - **Out of Scope**: UI development, authentication management beyond API key 23 | 24 | ## Timeline 25 | - Initial development: Complete 26 | - Release workflow: In progress 27 | - Future enhancements: TBD 28 | 29 | ## Success Criteria 30 | - All specified Linear operations work correctly through MCP tools 31 | - Server handles errors gracefully 32 | - Documentation is clear and comprehensive 33 | - Release process is automated 34 | ``` -------------------------------------------------------------------------------- /pkg/tools/get_teams.go: -------------------------------------------------------------------------------- ```go 1 | package tools 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/geropl/linear-mcp-go/pkg/linear" 8 | "github.com/mark3labs/mcp-go/mcp" 9 | ) 10 | 11 | // GetTeamsTool is the tool definition for getting teams 12 | var GetTeamsTool = mcp.NewTool("linear_get_teams", 13 | mcp.WithDescription("Retrieves Linear teams."), 14 | mcp.WithString("name", mcp.Description("Optional team name filter. Returns teams whose names contain this string.")), 15 | ) 16 | 17 | // GetTeamsHandler handles the linear_get_teams tool 18 | func GetTeamsHandler(linearClient *linear.LinearClient) func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { 19 | return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { 20 | // Extract arguments 21 | name := request.GetString("name", "") 22 | 23 | // Get teams 24 | teams, err := linearClient.GetTeams(name) 25 | if err != nil { 26 | return &mcp.CallToolResult{IsError: true, Content: []mcp.Content{mcp.TextContent{Type: "text", Text: fmt.Sprintf("Failed to get teams: %v", err)}}}, nil 27 | } 28 | 29 | // Format the result 30 | resultText := fmt.Sprintf("Found %d teams:\n", len(teams)) 31 | for _, team := range teams { 32 | // Create a pointer to the team for formatTeamIdentifier 33 | teamPtr := &team 34 | resultText += fmt.Sprintf("- %s\n", formatTeamIdentifier(teamPtr)) 35 | resultText += fmt.Sprintf(" Key: %s\n", team.Key) 36 | } 37 | 38 | return &mcp.CallToolResult{Content: []mcp.Content{mcp.TextContent{Type: "text", Text: resultText}}}, nil 39 | } 40 | } 41 | ``` -------------------------------------------------------------------------------- /cmd/serve.go: -------------------------------------------------------------------------------- ```go 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "strings" 7 | 8 | "github.com/geropl/linear-mcp-go/pkg/server" 9 | "github.com/spf13/cobra" 10 | ) 11 | 12 | // serveCmd represents the serve command 13 | var serveCmd = &cobra.Command{ 14 | Use: "serve", 15 | Short: "Start the Linear MCP server", 16 | Long: `Start the Linear MCP server that listens for MCP requests on stdin/stdout. 17 | The server provides tools for interacting with the Linear API through the MCP protocol.`, 18 | Run: func(cmd *cobra.Command, args []string) { 19 | writeAccess, _ := cmd.Flags().GetBool("write-access") 20 | writeAccessChanged := cmd.Flags().Changed("write-access") 21 | 22 | // Check LINEAR_WRITE_ACCESS environment variable if flag wasn't explicitly set 23 | if !writeAccessChanged { 24 | if envWriteAccess := os.Getenv("LINEAR_WRITE_ACCESS"); envWriteAccess != "" { 25 | envValue := strings.ToLower(strings.TrimSpace(envWriteAccess)) 26 | if envValue == "true" { 27 | writeAccess = true 28 | } else if envValue == "false" { 29 | writeAccess = false 30 | } 31 | // If the env var is set to something other than "true" or "false", ignore it and use default 32 | } 33 | } 34 | 35 | // Create the Linear MCP server 36 | linearServer, err := server.NewLinearMCPServer(writeAccess) 37 | if err != nil { 38 | fmt.Printf("Failed to create Linear MCP server: %v\n", err) 39 | os.Exit(1) 40 | } 41 | 42 | // Start the server 43 | if err := linearServer.Start(); err != nil { 44 | fmt.Printf("Server error: %v\n", err) 45 | os.Exit(1) 46 | } 47 | }, 48 | } 49 | 50 | func init() { 51 | rootCmd.AddCommand(serveCmd) 52 | 53 | // Add flags to the serve command 54 | serveCmd.Flags().Bool("write-access", false, "Enable tools that modify Linear data (create/update issues, add comments)") 55 | } 56 | ``` -------------------------------------------------------------------------------- /testdata/fixtures/get_issue_comments_handler_Invalid issue.yaml: -------------------------------------------------------------------------------- ```yaml 1 | --- 2 | version: 2 3 | interactions: 4 | - id: 0 5 | request: 6 | proto: HTTP/1.1 7 | proto_major: 1 8 | proto_minor: 1 9 | content_length: 330 10 | transfer_encoding: [] 11 | trailer: {} 12 | host: api.linear.app 13 | remote_addr: "" 14 | request_uri: "" 15 | body: '{"query":"\n\t\tquery GetIssueByIdentifier($teamKey: String!, $number: Float!) {\n\t\t\tissues(filter: { team: { key: { eq: $teamKey } }, number: { eq: $number } }, first: 1) {\n\t\t\t\tnodes {\n\t\t\t\t\tid\n\t\t\t\t\tidentifier\n\t\t\t\t\ttitle\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t","variables":{"number":123,"teamKey":"NONEXISTENT"}}' 16 | form: {} 17 | headers: 18 | Content-Type: 19 | - application/json 20 | url: https://api.linear.app/graphql 21 | method: POST 22 | response: 23 | proto: HTTP/2.0 24 | proto_major: 2 25 | proto_minor: 0 26 | transfer_encoding: [] 27 | trailer: {} 28 | content_length: 33 29 | uncompressed: false 30 | body: | 31 | {"data":{"issues":{"nodes":[]}}} 32 | headers: 33 | Alt-Svc: 34 | - h3=":443"; ma=86400 35 | Cache-Control: 36 | - no-store 37 | Cf-Cache-Status: 38 | - DYNAMIC 39 | Content-Length: 40 | - "33" 41 | Content-Type: 42 | - application/json; charset=utf-8 43 | Etag: 44 | - W/"21-PKFa7EZ3q+7ITZ8vZtp2aqgNJxo" 45 | Server: 46 | - cloudflare 47 | Vary: 48 | - Accept-Encoding 49 | Via: 50 | - 1.1 google 51 | status: 200 OK 52 | code: 200 53 | duration: 0s 54 | ``` -------------------------------------------------------------------------------- /testdata/fixtures/get_issue_handler_Missing issueId.yaml: -------------------------------------------------------------------------------- ```yaml 1 | --- 2 | version: 2 3 | interactions: 4 | - id: 0 5 | request: 6 | proto: HTTP/1.1 7 | proto_major: 1 8 | proto_minor: 1 9 | content_length: 330 10 | transfer_encoding: [] 11 | trailer: {} 12 | host: api.linear.app 13 | remote_addr: "" 14 | request_uri: "" 15 | body: '{"query":"\n\t\tquery GetIssueByIdentifier($teamKey: String!, $number: Float!) {\n\t\t\tissues(filter: { team: { key: { eq: $teamKey } }, number: { eq: $number } }, first: 1) {\n\t\t\t\tnodes {\n\t\t\t\t\tid\n\t\t\t\t\tidentifier\n\t\t\t\t\ttitle\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t","variables":{"number":123,"teamKey":"NONEXISTENT"}}' 16 | form: {} 17 | headers: 18 | Content-Type: 19 | - application/json 20 | url: https://api.linear.app/graphql 21 | method: POST 22 | response: 23 | proto: HTTP/2.0 24 | proto_major: 2 25 | proto_minor: 0 26 | transfer_encoding: [] 27 | trailer: {} 28 | content_length: 33 29 | uncompressed: false 30 | body: | 31 | {"data":{"issues":{"nodes":[]}}} 32 | headers: 33 | Alt-Svc: 34 | - h3=":443"; ma=86400 35 | Cache-Control: 36 | - no-store 37 | Cf-Cache-Status: 38 | - DYNAMIC 39 | Content-Length: 40 | - "33" 41 | Content-Type: 42 | - application/json; charset=utf-8 43 | Etag: 44 | - W/"21-PKFa7EZ3q+7ITZ8vZtp2aqgNJxo" 45 | Server: 46 | - cloudflare 47 | Vary: 48 | - Accept-Encoding 49 | Via: 50 | - 1.1 google 51 | status: 200 OK 52 | code: 200 53 | duration: 0s 54 | ``` -------------------------------------------------------------------------------- /testdata/fixtures/get_initiative_handler_Valid initiative.yaml: -------------------------------------------------------------------------------- ```yaml 1 | --- 2 | version: 2 3 | interactions: 4 | - id: 0 5 | request: 6 | proto: HTTP/1.1 7 | proto_major: 1 8 | proto_minor: 1 9 | content_length: 220 10 | transfer_encoding: [] 11 | trailer: {} 12 | host: api.linear.app 13 | remote_addr: "" 14 | request_uri: "" 15 | body: '{"query":"\n\t\tquery GetInitiative($id: String!) {\n\t\t\tinitiative(id: $id) {\n\t\t\t\tid\n\t\t\t\tname\n\t\t\t\tdescription\n\t\t\t\turl\n\t\t\t}\n\t\t}\n\t","variables":{"id":"3bb752a7-897e-4240-9306-01e48872fab3"}}' 16 | form: {} 17 | headers: 18 | Content-Type: 19 | - application/json 20 | url: https://api.linear.app/graphql 21 | method: POST 22 | response: 23 | proto: HTTP/2.0 24 | proto_major: 2 25 | proto_minor: 0 26 | transfer_encoding: [] 27 | trailer: {} 28 | content_length: -1 29 | uncompressed: true 30 | body: | 31 | {"data":{"initiative":{"id":"3bb752a7-897e-4240-9306-01e48872fab3","name":"Created Test Initiative","description":null,"url":"https://linear.app/linear-mcp-go-test/initiative/created-test-initiative-7ed59af889f6"}}} 32 | headers: 33 | Alt-Svc: 34 | - h3=":443"; ma=86400 35 | Cache-Control: 36 | - no-store 37 | Cf-Cache-Status: 38 | - DYNAMIC 39 | Content-Type: 40 | - application/json; charset=utf-8 41 | Etag: 42 | - W/"d8-hd6//06VmE0Pm1n9UOQfuP2hyLQ" 43 | Server: 44 | - cloudflare 45 | Vary: 46 | - Accept-Encoding 47 | Via: 48 | - 1.1 google 49 | status: 200 OK 50 | code: 200 51 | duration: 0s 52 | ``` -------------------------------------------------------------------------------- /testdata/fixtures/add_comment_handler_Missing body.yaml: -------------------------------------------------------------------------------- ```yaml 1 | --- 2 | version: 2 3 | interactions: 4 | - id: 0 5 | request: 6 | proto: HTTP/1.1 7 | proto_major: 1 8 | proto_minor: 1 9 | content_length: 322 10 | transfer_encoding: [] 11 | trailer: {} 12 | host: api.linear.app 13 | remote_addr: "" 14 | request_uri: "" 15 | body: '{"query":"\n\t\tquery GetIssueByIdentifier($teamKey: String!, $number: Float!) {\n\t\t\tissues(filter: { team: { key: { eq: $teamKey } }, number: { eq: $number } }, first: 1) {\n\t\t\t\tnodes {\n\t\t\t\t\tid\n\t\t\t\t\tidentifier\n\t\t\t\t\ttitle\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t","variables":{"number":10,"teamKey":"TEST"}}' 16 | form: {} 17 | headers: 18 | Content-Type: 19 | - application/json 20 | url: https://api.linear.app/graphql 21 | method: POST 22 | response: 23 | proto: HTTP/2.0 24 | proto_major: 2 25 | proto_minor: 0 26 | transfer_encoding: [] 27 | trailer: {} 28 | content_length: -1 29 | uncompressed: true 30 | body: | 31 | {"data":{"issues":{"nodes":[{"id":"1c2de93f-4321-4015-bfde-ee893ef7976f","identifier":"TEST-10","title":"Updated Test Issue"}]}}} 32 | headers: 33 | Alt-Svc: 34 | - h3=":443"; ma=86400 35 | Cache-Control: 36 | - no-store 37 | Cf-Cache-Status: 38 | - DYNAMIC 39 | Content-Type: 40 | - application/json; charset=utf-8 41 | Etag: 42 | - W/"82-w0K/VnjlqJtYAurPyBwU/9QgAFo" 43 | Server: 44 | - cloudflare 45 | Vary: 46 | - Accept-Encoding 47 | Via: 48 | - 1.1 google 49 | status: 200 OK 50 | code: 200 51 | duration: 0s 52 | ``` -------------------------------------------------------------------------------- /pkg/tools/priority.go: -------------------------------------------------------------------------------- ```go 1 | package tools 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | "strings" 7 | 8 | "github.com/mark3labs/mcp-go/mcp" 9 | ) 10 | 11 | const ( 12 | PriorityNone = 0 13 | PriorityUrgent = 1 14 | PriorityHigh = 2 15 | PriorityMedium = 3 16 | PriorityLow = 4 17 | ) 18 | 19 | var priorityNames = map[int]string{ 20 | PriorityNone: "No priority", 21 | PriorityUrgent: "Urgent", 22 | PriorityHigh: "High", 23 | PriorityMedium: "Medium", 24 | PriorityLow: "Low", 25 | } 26 | 27 | var priorityFromName = map[string]int{ 28 | "no priority": PriorityNone, 29 | "none": PriorityNone, 30 | "urgent": PriorityUrgent, 31 | "high": PriorityHigh, 32 | "medium": PriorityMedium, 33 | "low": PriorityLow, 34 | } 35 | 36 | // priorityToString converts numeric priority to textual representation 37 | func priorityToString(priority int) string { 38 | if name, ok := priorityNames[priority]; ok { 39 | return name 40 | } 41 | return "Unknown" 42 | } 43 | 44 | // parsePriority accepts both numeric (0-4) and textual representations 45 | // Returns the numeric value and an error if invalid 46 | func parsePriority(input string) (int, error) { 47 | input = strings.TrimSpace(strings.ToLower(input)) 48 | 49 | // Try parsing as number first 50 | if num, err := strconv.Atoi(input); err == nil { 51 | if num >= PriorityNone && num <= PriorityLow { 52 | return num, nil 53 | } 54 | return 0, fmt.Errorf("priority number must be between 0 and 4, got %d", num) 55 | } 56 | 57 | // Try parsing as text 58 | if priority, ok := priorityFromName[input]; ok { 59 | return priority, nil 60 | } 61 | 62 | return 0, fmt.Errorf("invalid priority: %s (valid values: 0-4, no priority, urgent, high, medium, low)", input) 63 | } 64 | 65 | // getPriorityOptions returns the property options for priority parameters 66 | func getPriorityOptions() []mcp.PropertyOption { 67 | return []mcp.PropertyOption{ 68 | mcp.Description("Priority"), 69 | mcp.Enum("no priority", "urgent", "high", "medium", "low"), 70 | } 71 | } 72 | ``` -------------------------------------------------------------------------------- /testdata/fixtures/get_milestone_handler_Valid milestone.yaml: -------------------------------------------------------------------------------- ```yaml 1 | --- 2 | version: 2 3 | interactions: 4 | - id: 0 5 | request: 6 | proto: HTTP/1.1 7 | proto_major: 1 8 | proto_minor: 1 9 | content_length: 296 10 | transfer_encoding: [] 11 | trailer: {} 12 | host: api.linear.app 13 | remote_addr: "" 14 | request_uri: "" 15 | body: '{"query":"\n\t\tquery ProjectMilestone($id: String!) {\n\t\t\tprojectMilestone(id: $id) {\n\t\t\t\tid\n\t\t\t\tname\n\t\t\t\tdescription\n\t\t\t\ttargetDate\n\t\t\t\tproject {\n\t\t\t\t\tid\n\t\t\t\t\tname\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t","variables":{"id":"c86acc00-3035-4a67-82f2-2a5bf6453e92"}}' 16 | form: {} 17 | headers: 18 | Content-Type: 19 | - application/json 20 | url: https://api.linear.app/graphql 21 | method: POST 22 | response: 23 | proto: HTTP/2.0 24 | proto_major: 2 25 | proto_minor: 0 26 | transfer_encoding: [] 27 | trailer: {} 28 | content_length: -1 29 | uncompressed: true 30 | body: | 31 | {"data":{"projectMilestone":{"id":"c86acc00-3035-4a67-82f2-2a5bf6453e92","name":"Updated Milestone Name","description":"Updated Description","targetDate":"2025-01-01","project":{"id":"bfa49864-16c9-44db-994e-a11ba2b386f1","name":"Updated Project Name 2"}}}} 32 | headers: 33 | Alt-Svc: 34 | - h3=":443"; ma=86400 35 | Cache-Control: 36 | - no-store 37 | Cf-Cache-Status: 38 | - DYNAMIC 39 | Content-Type: 40 | - application/json; charset=utf-8 41 | Etag: 42 | - W/"102-BS+utz/wRqBQhmWIePACw/zkABE" 43 | Server: 44 | - cloudflare 45 | Vary: 46 | - Accept-Encoding 47 | Via: 48 | - 1.1 google 49 | status: 200 OK 50 | code: 200 51 | duration: 0s 52 | ``` -------------------------------------------------------------------------------- /testdata/fixtures/create_initiative_handler_Valid initiative.yaml: -------------------------------------------------------------------------------- ```yaml 1 | --- 2 | version: 2 3 | interactions: 4 | - id: 0 5 | request: 6 | proto: HTTP/1.1 7 | proto_major: 1 8 | proto_minor: 1 9 | content_length: 313 10 | transfer_encoding: [] 11 | trailer: {} 12 | host: api.linear.app 13 | remote_addr: "" 14 | request_uri: "" 15 | body: '{"query":"\n\t\tmutation InitiativeCreate($input: InitiativeCreateInput!) {\n\t\t\tinitiativeCreate(input: $input) {\n\t\t\t\tsuccess\n\t\t\t\tinitiative {\n\t\t\t\t\tid\n\t\t\t\t\tname\n\t\t\t\t\tdescription\n\t\t\t\t\turl\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t","variables":{"input":{"name":"Created Test Initiative"}}}' 16 | form: {} 17 | headers: 18 | Content-Type: 19 | - application/json 20 | url: https://api.linear.app/graphql 21 | method: POST 22 | response: 23 | proto: HTTP/2.0 24 | proto_major: 2 25 | proto_minor: 0 26 | transfer_encoding: [] 27 | trailer: {} 28 | content_length: -1 29 | uncompressed: true 30 | body: | 31 | {"data":{"initiativeCreate":{"success":true,"initiative":{"id":"3bb752a7-897e-4240-9306-01e48872fab3","name":"Created Test Initiative","description":null,"url":"https://linear.app/linear-mcp-go-test/initiative/created-test-initiative-7ed59af889f6"}}}} 32 | headers: 33 | Alt-Svc: 34 | - h3=":443"; ma=86400 35 | Cache-Control: 36 | - no-store 37 | Cf-Cache-Status: 38 | - DYNAMIC 39 | Content-Type: 40 | - application/json; charset=utf-8 41 | Etag: 42 | - W/"fc-gsDkifP3AXu0S0n8HWLEunvFm3g" 43 | Server: 44 | - cloudflare 45 | Vary: 46 | - Accept-Encoding 47 | Via: 48 | - 1.1 google 49 | status: 200 OK 50 | code: 200 51 | duration: 0s 52 | ``` -------------------------------------------------------------------------------- /testdata/fixtures/update_comment_handler_Invalid comment identifier.yaml: -------------------------------------------------------------------------------- ```yaml 1 | --- 2 | version: 2 3 | interactions: 4 | - id: 0 5 | request: 6 | proto: HTTP/1.1 7 | proto_major: 1 8 | proto_minor: 1 9 | content_length: 265 10 | transfer_encoding: [] 11 | trailer: {} 12 | host: api.linear.app 13 | remote_addr: "" 14 | request_uri: "" 15 | body: '{"query":"\n\t\tquery GetCommentByHash($hash: String!) {\n\t\t\tcomment(hash: $hash) {\n\t\t\t\tid\n\t\t\t\tbody\n\t\t\t\turl\n\t\t\t\tcreatedAt\n\t\t\t\tuser {\n\t\t\t\t\tid\n\t\t\t\t\tname\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t","variables":{"hash":"invalid-comment-id"}}' 16 | form: {} 17 | headers: 18 | Content-Type: 19 | - application/json 20 | url: https://api.linear.app/graphql 21 | method: POST 22 | response: 23 | proto: HTTP/2.0 24 | proto_major: 2 25 | proto_minor: 0 26 | transfer_encoding: [] 27 | trailer: {} 28 | content_length: -1 29 | uncompressed: true 30 | body: | 31 | {"errors":[{"message":"Entity not found: Comment: could not find by hash","path":["comment"],"locations":[{"line":3,"column":4}],"extensions":{"type":"invalid input","code":"INPUT_ERROR","statusCode":400,"userError":true,"userPresentableMessage":"Could not find referenced comment."}}],"data":null} 32 | headers: 33 | Alt-Svc: 34 | - h3=":443"; ma=86400 35 | Cache-Control: 36 | - no-store 37 | Cf-Cache-Status: 38 | - DYNAMIC 39 | Content-Type: 40 | - application/json; charset=utf-8 41 | Etag: 42 | - W/"12b-3YQeaABMZ487ZUXapKwikGiK6Xw" 43 | Server: 44 | - cloudflare 45 | Vary: 46 | - Accept-Encoding 47 | Via: 48 | - 1.1 google 49 | status: 200 OK 50 | code: 200 51 | duration: 0s 52 | ``` -------------------------------------------------------------------------------- /testdata/fixtures/search_projects_handler_Search by query.yaml: -------------------------------------------------------------------------------- ```yaml 1 | --- 2 | version: 2 3 | interactions: 4 | - id: 0 5 | request: 6 | proto: HTTP/1.1 7 | proto_major: 1 8 | proto_minor: 1 9 | content_length: 311 10 | transfer_encoding: [] 11 | trailer: {} 12 | host: api.linear.app 13 | remote_addr: "" 14 | request_uri: "" 15 | body: '{"query":"\n\t\tquery SearchProjects($filter: ProjectFilter) {\n\t\t\tprojects(filter: $filter) {\n\t\t\t\tnodes {\n\t\t\t\t\tid\n\t\t\t\t\tname\n\t\t\t\t\tdescription\n\t\t\t\t\tslugId\n\t\t\t\t\tstate\n\t\t\t\t\turl\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t","variables":{"filter":{"name":{"containsIgnoreCase":"mcp"}}}}' 16 | form: {} 17 | headers: 18 | Content-Type: 19 | - application/json 20 | url: https://api.linear.app/graphql 21 | method: POST 22 | response: 23 | proto: HTTP/2.0 24 | proto_major: 2 25 | proto_minor: 0 26 | transfer_encoding: [] 27 | trailer: {} 28 | content_length: -1 29 | uncompressed: true 30 | body: | 31 | {"data":{"projects":{"nodes":[{"id":"01bff2dd-ab7f-4464-b425-97073862013f","name":"MCP tool investigation","description":"Summary text goes here","slugId":"ae44897e42a7","state":"backlog","url":"https://linear.app/linear-mcp-go-test/project/mcp-tool-investigation-ae44897e42a7"}]}}} 32 | headers: 33 | Alt-Svc: 34 | - h3=":443"; ma=86400 35 | Cache-Control: 36 | - no-store 37 | Cf-Cache-Status: 38 | - DYNAMIC 39 | Content-Type: 40 | - application/json; charset=utf-8 41 | Etag: 42 | - W/"11b-k8WmIaSsWOTOQAk9sZK3/3eZoaY" 43 | Server: 44 | - cloudflare 45 | Vary: 46 | - Accept-Encoding 47 | Via: 48 | - 1.1 google 49 | status: 200 OK 50 | code: 200 51 | duration: 0s 52 | ``` -------------------------------------------------------------------------------- /testdata/fixtures/create_initiative_handler_With description.yaml: -------------------------------------------------------------------------------- ```yaml 1 | --- 2 | version: 2 3 | interactions: 4 | - id: 0 5 | request: 6 | proto: HTTP/1.1 7 | proto_major: 1 8 | proto_minor: 1 9 | content_length: 348 10 | transfer_encoding: [] 11 | trailer: {} 12 | host: api.linear.app 13 | remote_addr: "" 14 | request_uri: "" 15 | body: '{"query":"\n\t\tmutation InitiativeCreate($input: InitiativeCreateInput!) {\n\t\t\tinitiativeCreate(input: $input) {\n\t\t\t\tsuccess\n\t\t\t\tinitiative {\n\t\t\t\t\tid\n\t\t\t\t\tname\n\t\t\t\t\tdescription\n\t\t\t\t\turl\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t","variables":{"input":{"name":"Created Test Initiative 2","description":"Test Description"}}}' 16 | form: {} 17 | headers: 18 | Content-Type: 19 | - application/json 20 | url: https://api.linear.app/graphql 21 | method: POST 22 | response: 23 | proto: HTTP/2.0 24 | proto_major: 2 25 | proto_minor: 0 26 | transfer_encoding: [] 27 | trailer: {} 28 | content_length: -1 29 | uncompressed: true 30 | body: | 31 | {"data":{"initiativeCreate":{"success":true,"initiative":{"id":"c6a7dd0c-cbe2-4101-906d-ddd97acb2241","name":"Created Test Initiative 2","description":"Test Description","url":"https://linear.app/linear-mcp-go-test/initiative/created-test-initiative-2-e209008074dc"}}}} 32 | headers: 33 | Alt-Svc: 34 | - h3=":443"; ma=86400 35 | Cache-Control: 36 | - no-store 37 | Cf-Cache-Status: 38 | - DYNAMIC 39 | Content-Type: 40 | - application/json; charset=utf-8 41 | Etag: 42 | - W/"10e-Z2O6jMzejJ/UoHu3z6jnwHPqPzc" 43 | Server: 44 | - cloudflare 45 | Vary: 46 | - Accept-Encoding 47 | Via: 48 | - 1.1 google 49 | status: 200 OK 50 | code: 200 51 | duration: 0s 52 | ``` -------------------------------------------------------------------------------- /pkg/tools/priority_test.go: -------------------------------------------------------------------------------- ```go 1 | package tools 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestParsePriority(t *testing.T) { 8 | tests := []struct { 9 | name string 10 | input string 11 | want int 12 | wantErr bool 13 | }{ 14 | // Numeric inputs 15 | {"zero", "0", 0, false}, 16 | {"one", "1", 1, false}, 17 | {"two", "2", 2, false}, 18 | {"three", "3", 3, false}, 19 | {"four", "4", 4, false}, 20 | {"invalid number", "5", 0, true}, 21 | {"negative", "-1", 0, true}, 22 | 23 | // Textual inputs (lowercase) 24 | {"no priority", "no priority", 0, false}, 25 | {"none", "none", 0, false}, 26 | {"urgent", "urgent", 1, false}, 27 | {"high", "high", 2, false}, 28 | {"medium", "medium", 3, false}, 29 | {"low", "low", 4, false}, 30 | 31 | // Textual inputs (mixed case) 32 | {"Urgent", "Urgent", 1, false}, 33 | {"HIGH", "HIGH", 2, false}, 34 | {"MeDiUm", "MeDiUm", 3, false}, 35 | 36 | // Whitespace handling 37 | {"with spaces", " urgent ", 1, false}, 38 | {"with tabs", "\thigh\t", 2, false}, 39 | 40 | // Invalid inputs 41 | {"invalid text", "super-urgent", 0, true}, 42 | {"empty", "", 0, true}, 43 | {"random", "xyz", 0, true}, 44 | } 45 | 46 | for _, tt := range tests { 47 | t.Run(tt.name, func(t *testing.T) { 48 | got, err := parsePriority(tt.input) 49 | if (err != nil) != tt.wantErr { 50 | t.Errorf("parsePriority(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr) 51 | return 52 | } 53 | if got != tt.want { 54 | t.Errorf("parsePriority(%q) = %v, want %v", tt.input, got, tt.want) 55 | } 56 | }) 57 | } 58 | } 59 | 60 | func TestPriorityToString(t *testing.T) { 61 | tests := []struct { 62 | name string 63 | priority int 64 | want string 65 | }{ 66 | {"zero", 0, "No priority"}, 67 | {"urgent", 1, "Urgent"}, 68 | {"high", 2, "High"}, 69 | {"medium", 3, "Medium"}, 70 | {"low", 4, "Low"}, 71 | {"invalid", 5, "Unknown"}, 72 | {"negative", -1, "Unknown"}, 73 | } 74 | 75 | for _, tt := range tests { 76 | t.Run(tt.name, func(t *testing.T) { 77 | if got := priorityToString(tt.priority); got != tt.want { 78 | t.Errorf("priorityToString(%d) = %v, want %v", tt.priority, got, tt.want) 79 | } 80 | }) 81 | } 82 | } 83 | ``` -------------------------------------------------------------------------------- /testdata/fixtures/search_projects_handler_No results.yaml: -------------------------------------------------------------------------------- ```yaml 1 | --- 2 | version: 2 3 | interactions: 4 | - id: 0 5 | request: 6 | proto: HTTP/1.1 7 | proto_major: 1 8 | proto_minor: 1 9 | content_length: 564 10 | transfer_encoding: [] 11 | trailer: {} 12 | host: api.linear.app 13 | remote_addr: "" 14 | request_uri: "" 15 | body: '{"query":"\n\t\tquery SearchProjects($filter: ProjectFilter) {\n\t\t\tprojects(filter: $filter) {\n\t\t\t\tnodes {\n\t\t\t\t\tid\n\t\t\t\t\tname\n\t\t\t\t\tdescription\n\t\t\t\t\tslugId\n\t\t\t\t\tstate\n\t\t\t\t\turl\n\t\t\t\t\tinitiatives(first: 1) {\n\t\t\t\t\t\tnodes {\n\t\t\t\t\t\t\tid\n\t\t\t\t\t\t\tname\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\tlead {\n\t\t\t\t\t\tid\n\t\t\t\t\t\tname\n\t\t\t\t\t}\n\t\t\t\t\tstartDate\n\t\t\t\t\ttargetDate\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t","variables":{"filter":{"name":{"containsIgnoreCase":"non-existent-project-query"}}}}' 16 | form: {} 17 | headers: 18 | Content-Type: 19 | - application/json 20 | url: https://api.linear.app/graphql 21 | method: POST 22 | response: 23 | proto: HTTP/2.0 24 | proto_major: 2 25 | proto_minor: 0 26 | transfer_encoding: [] 27 | trailer: {} 28 | content_length: 35 29 | uncompressed: false 30 | body: | 31 | {"data":{"projects":{"nodes":[]}}} 32 | headers: 33 | Alt-Svc: 34 | - h3=":443"; ma=86400 35 | Cache-Control: 36 | - no-store 37 | Cf-Cache-Status: 38 | - DYNAMIC 39 | Content-Length: 40 | - "35" 41 | Content-Type: 42 | - application/json; charset=utf-8 43 | Etag: 44 | - W/"23-qdJEPQ25XhtziwkPAN9bwg0W7eo" 45 | Server: 46 | - cloudflare 47 | Vary: 48 | - Accept-Encoding 49 | Via: 50 | - 1.1 google 51 | status: 200 OK 52 | code: 200 53 | duration: 0s 54 | ``` -------------------------------------------------------------------------------- /testdata/fixtures/create_project_handler_Valid project.yaml: -------------------------------------------------------------------------------- ```yaml 1 | --- 2 | version: 2 3 | interactions: 4 | - id: 0 5 | request: 6 | proto: HTTP/1.1 7 | proto_major: 1 8 | proto_minor: 1 9 | content_length: 384 10 | transfer_encoding: [] 11 | trailer: {} 12 | host: api.linear.app 13 | remote_addr: "" 14 | request_uri: "" 15 | body: '{"query":"\n\t\tmutation ProjectCreate($input: ProjectCreateInput!) {\n\t\t\tprojectCreate(input: $input) {\n\t\t\t\tsuccess\n\t\t\t\tproject {\n\t\t\t\t\tid\n\t\t\t\t\tname\n\t\t\t\t\tdescription\n\t\t\t\t\tslugId\n\t\t\t\t\tstate\n\t\t\t\t\turl\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t","variables":{"input":{"name":"Created Test Project","teamIds":["234c5451-a839-4c8f-98d9-da00973f1060"]}}}' 16 | form: {} 17 | headers: 18 | Content-Type: 19 | - application/json 20 | url: https://api.linear.app/graphql 21 | method: POST 22 | response: 23 | proto: HTTP/2.0 24 | proto_major: 2 25 | proto_minor: 0 26 | transfer_encoding: [] 27 | trailer: {} 28 | content_length: -1 29 | uncompressed: true 30 | body: | 31 | {"data":{"projectCreate":{"success":true,"project":{"id":"1c3f69d6-ab7b-4339-906d-5d63bd3cc3bc","name":"Created Test Project","description":"","slugId":"d1e7a63515a4","state":"backlog","url":"https://linear.app/linear-mcp-go-test/project/created-test-project-d1e7a63515a4"}}}} 32 | headers: 33 | Alt-Svc: 34 | - h3=":443"; ma=86400 35 | Cache-Control: 36 | - no-store 37 | Cf-Cache-Status: 38 | - DYNAMIC 39 | Content-Type: 40 | - application/json; charset=utf-8 41 | Etag: 42 | - W/"115-3XE+XxWz8n/te2YxwxwQ8jghgOQ" 43 | Server: 44 | - cloudflare 45 | Vary: 46 | - Accept-Encoding 47 | Via: 48 | - 1.1 google 49 | status: 200 OK 50 | code: 200 51 | duration: 0s 52 | ``` -------------------------------------------------------------------------------- /testdata/fixtures/create_milestone_handler_Valid milestone.yaml: -------------------------------------------------------------------------------- ```yaml 1 | --- 2 | version: 2 3 | interactions: 4 | - id: 0 5 | request: 6 | proto: HTTP/1.1 7 | proto_major: 1 8 | proto_minor: 1 9 | content_length: 458 10 | transfer_encoding: [] 11 | trailer: {} 12 | host: api.linear.app 13 | remote_addr: "" 14 | request_uri: "" 15 | body: '{"query":"\n\t\tmutation ProjectMilestoneCreate($input: ProjectMilestoneCreateInput!) {\n\t\t\tprojectMilestoneCreate(input: $input) {\n\t\t\t\tsuccess\n\t\t\t\tprojectMilestone {\n\t\t\t\t\tid\n\t\t\t\t\tname\n\t\t\t\t\tdescription\n\t\t\t\t\ttargetDate\n\t\t\t\t\tproject {\n\t\t\t\t\t\tid\n\t\t\t\t\t\tname\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t","variables":{"input":{"name":"Test Milestone 2.2","projectId":"bfa49864-16c9-44db-994e-a11ba2b386f1"}}}' 16 | form: {} 17 | headers: 18 | Content-Type: 19 | - application/json 20 | url: https://api.linear.app/graphql 21 | method: POST 22 | response: 23 | proto: HTTP/2.0 24 | proto_major: 2 25 | proto_minor: 0 26 | transfer_encoding: [] 27 | trailer: {} 28 | content_length: -1 29 | uncompressed: true 30 | body: | 31 | {"data":{"projectMilestoneCreate":{"success":true,"projectMilestone":{"id":"2d95299d-1341-484b-ab00-5cb587f2cc67","name":"Test Milestone 2.2","description":null,"targetDate":null,"project":{"id":"bfa49864-16c9-44db-994e-a11ba2b386f1","name":"Updated Project Name 2"}}}}} 32 | headers: 33 | Alt-Svc: 34 | - h3=":443"; ma=86400 35 | Cache-Control: 36 | - no-store 37 | Cf-Cache-Status: 38 | - DYNAMIC 39 | Content-Type: 40 | - application/json; charset=utf-8 41 | Etag: 42 | - W/"10f-qfcBByYXxFvyqjb46UAlsOog79A" 43 | Server: 44 | - cloudflare 45 | Vary: 46 | - Accept-Encoding 47 | Via: 48 | - 1.1 google 49 | status: 200 OK 50 | code: 200 51 | duration: 0s 52 | ``` -------------------------------------------------------------------------------- /testdata/fixtures/create_project_handler_With all optional fields.yaml: -------------------------------------------------------------------------------- ```yaml 1 | --- 2 | version: 2 3 | interactions: 4 | - id: 0 5 | request: 6 | proto: HTTP/1.1 7 | proto_major: 1 8 | proto_minor: 1 9 | content_length: 510 10 | transfer_encoding: [] 11 | trailer: {} 12 | host: api.linear.app 13 | remote_addr: "" 14 | request_uri: "" 15 | body: '{"query":"\n\t\tmutation ProjectCreate($input: ProjectCreateInput!) {\n\t\t\tprojectCreate(input: $input) {\n\t\t\t\tsuccess\n\t\t\t\tproject {\n\t\t\t\t\tid\n\t\t\t\t\tname\n\t\t\t\t\tdescription\n\t\t\t\t\tslugId\n\t\t\t\t\tstate\n\t\t\t\t\turl\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t","variables":{"input":{"name":"Test Project 2","teamIds":["234c5451-a839-4c8f-98d9-da00973f1060"],"description":"Test Description","leadId":"cc24eee4-9edc-4bfe-b91b-fedde125ba85","startDate":"2024-01-01","targetDate":"2024-12-31"}}}' 16 | form: {} 17 | headers: 18 | Content-Type: 19 | - application/json 20 | url: https://api.linear.app/graphql 21 | method: POST 22 | response: 23 | proto: HTTP/2.0 24 | proto_major: 2 25 | proto_minor: 0 26 | transfer_encoding: [] 27 | trailer: {} 28 | content_length: -1 29 | uncompressed: true 30 | body: | 31 | {"data":{"projectCreate":{"success":true,"project":{"id":"5ce3e62b-766e-44d9-b7a4-e335492bfd1e","name":"Test Project 2","description":"Test Description","slugId":"b73665fc5cc5","state":"backlog","url":"https://linear.app/linear-mcp-go-test/project/test-project-2-b73665fc5cc5"}}}} 32 | headers: 33 | Alt-Svc: 34 | - h3=":443"; ma=86400 35 | Cache-Control: 36 | - no-store 37 | Cf-Cache-Status: 38 | - DYNAMIC 39 | Content-Type: 40 | - application/json; charset=utf-8 41 | Etag: 42 | - W/"119-Cuk8W8/7UhfXhWx3IaWopPfEYBU" 43 | Server: 44 | - cloudflare 45 | Vary: 46 | - Accept-Encoding 47 | Via: 48 | - 1.1 google 49 | status: 200 OK 50 | code: 200 51 | duration: 0s 52 | ``` -------------------------------------------------------------------------------- /testdata/fixtures/search_issues_handler_Search by query.yaml: -------------------------------------------------------------------------------- ```yaml 1 | --- 2 | version: 2 3 | interactions: 4 | - id: 0 5 | request: 6 | proto: HTTP/1.1 7 | proto_major: 1 8 | proto_minor: 1 9 | content_length: 716 10 | transfer_encoding: [] 11 | trailer: {} 12 | host: api.linear.app 13 | remote_addr: "" 14 | request_uri: "" 15 | body: '{"query":"\n\t\tquery SearchIssues($filter: IssueFilter, $first: Int, $includeArchived: Boolean) {\n\t\t\tissues(filter: $filter, first: $first, includeArchived: $includeArchived) {\n\t\t\t\tnodes {\n\t\t\t\t\tid\n\t\t\t\t\tidentifier\n\t\t\t\t\ttitle\n\t\t\t\t\tdescription\n\t\t\t\t\tpriority\n\t\t\t\t\turl\n\t\t\t\t\tstate {\n\t\t\t\t\t\tid\n\t\t\t\t\t\tname\n\t\t\t\t\t}\n\t\t\t\t\tassignee {\n\t\t\t\t\t\tid\n\t\t\t\t\t\tname\n\t\t\t\t\t}\n\t\t\t\t\tlabels {\n\t\t\t\t\t\tnodes {\n\t\t\t\t\t\t\tid\n\t\t\t\t\t\t\tname\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t","variables":{"filter":{"or":[{"title":{"contains":"test"}},{"description":{"contains":"test"}}]},"first":5,"includeArchived":false}}' 16 | form: {} 17 | headers: 18 | Content-Type: 19 | - application/json 20 | url: https://api.linear.app/graphql 21 | method: POST 22 | response: 23 | proto: HTTP/2.0 24 | proto_major: 2 25 | proto_minor: 0 26 | transfer_encoding: [] 27 | trailer: {} 28 | content_length: 33 29 | uncompressed: false 30 | body: | 31 | {"data":{"issues":{"nodes":[]}}} 32 | headers: 33 | Alt-Svc: 34 | - h3=":443"; ma=86400 35 | Cache-Control: 36 | - no-store 37 | Cf-Cache-Status: 38 | - DYNAMIC 39 | Content-Length: 40 | - "33" 41 | Content-Type: 42 | - application/json; charset=utf-8 43 | Etag: 44 | - W/"21-PKFa7EZ3q+7ITZ8vZtp2aqgNJxo" 45 | Server: 46 | - cloudflare 47 | Vary: 48 | - Accept-Encoding 49 | Via: 50 | - 1.1 google 51 | status: 200 OK 52 | code: 200 53 | duration: 0s 54 | ``` -------------------------------------------------------------------------------- /testdata/fixtures/create_milestone_handler_With all optional fields.yaml: -------------------------------------------------------------------------------- ```yaml 1 | --- 2 | version: 2 3 | interactions: 4 | - id: 0 5 | request: 6 | proto: HTTP/1.1 7 | proto_major: 1 8 | proto_minor: 1 9 | content_length: 517 10 | transfer_encoding: [] 11 | trailer: {} 12 | host: api.linear.app 13 | remote_addr: "" 14 | request_uri: "" 15 | body: '{"query":"\n\t\tmutation ProjectMilestoneCreate($input: ProjectMilestoneCreateInput!) {\n\t\t\tprojectMilestoneCreate(input: $input) {\n\t\t\t\tsuccess\n\t\t\t\tprojectMilestone {\n\t\t\t\t\tid\n\t\t\t\t\tname\n\t\t\t\t\tdescription\n\t\t\t\t\ttargetDate\n\t\t\t\t\tproject {\n\t\t\t\t\t\tid\n\t\t\t\t\t\tname\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t","variables":{"input":{"name":"Test Milestone 3.2","projectId":"bfa49864-16c9-44db-994e-a11ba2b386f1","description":"Test Description","targetDate":"2024-12-31"}}}' 16 | form: {} 17 | headers: 18 | Content-Type: 19 | - application/json 20 | url: https://api.linear.app/graphql 21 | method: POST 22 | response: 23 | proto: HTTP/2.0 24 | proto_major: 2 25 | proto_minor: 0 26 | transfer_encoding: [] 27 | trailer: {} 28 | content_length: -1 29 | uncompressed: true 30 | body: | 31 | {"data":{"projectMilestoneCreate":{"success":true,"projectMilestone":{"id":"7017befa-5b90-4511-9ddf-c1c6ae7ba99a","name":"Test Milestone 3.2","description":"Test Description","targetDate":"2024-12-31","project":{"id":"bfa49864-16c9-44db-994e-a11ba2b386f1","name":"Updated Project Name 2"}}}}} 32 | headers: 33 | Alt-Svc: 34 | - h3=":443"; ma=86400 35 | Cache-Control: 36 | - no-store 37 | Cf-Cache-Status: 38 | - DYNAMIC 39 | Content-Type: 40 | - application/json; charset=utf-8 41 | Etag: 42 | - W/"125-gez3UfbdT9/D0VXU4hZmuVEUokw" 43 | Server: 44 | - cloudflare 45 | Vary: 46 | - Accept-Encoding 47 | Via: 48 | - 1.1 google 49 | status: 200 OK 50 | code: 200 51 | duration: 0s 52 | ``` -------------------------------------------------------------------------------- /testdata/fixtures/create_issue_handler_Invalid team.yaml: -------------------------------------------------------------------------------- ```yaml 1 | --- 2 | version: 2 3 | interactions: 4 | - id: 0 5 | request: 6 | proto: HTTP/1.1 7 | proto_major: 1 8 | proto_minor: 1 9 | content_length: 310 10 | transfer_encoding: [] 11 | trailer: {} 12 | host: api.linear.app 13 | remote_addr: "" 14 | request_uri: "" 15 | body: '{"query":"\n\t\tquery GetTeams($filter: TeamFilter) {\n\t\t\tteams(filter: $filter) {\n\t\t\t\tnodes {\n\t\t\t\t\tid\n\t\t\t\t\tname\n\t\t\t\t\tkey\n\t\t\t\t\tdescription\n\t\t\t\t\tstates {\n\t\t\t\t\t\tnodes {\n\t\t\t\t\t\t\tid\n\t\t\t\t\t\t\tname\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t"}' 16 | form: {} 17 | headers: 18 | Content-Type: 19 | - application/json 20 | url: https://api.linear.app/graphql 21 | method: POST 22 | response: 23 | proto: HTTP/2.0 24 | proto_major: 2 25 | proto_minor: 0 26 | transfer_encoding: [] 27 | trailer: {} 28 | content_length: -1 29 | uncompressed: true 30 | body: | 31 | {"data":{"teams":{"nodes":[{"id":"234c5451-a839-4c8f-98d9-da00973f1060","name":"Test Team","key":"TEST","description":null,"states":{"nodes":[{"id":"d4caa373-1a02-431c-bd3f-1bbb67318617","name":"Done"},{"id":"cffb8999-f10e-447d-9672-8faf5b06ac67","name":"Todo"},{"id":"42f7ad15-fca3-4d11-b349-0e3c1385c256","name":"Backlog"},{"id":"2d26ea57-c1f7-43ae-ba30-3f828ac8edb6","name":"Canceled"},{"id":"2a939ee1-65a1-445c-8e5d-18239e5f64bc","name":"Duplicate"},{"id":"12bb7f66-d9be-4faa-800f-49b8e3b38a3f","name":"In Progress"}]}}]}}} 32 | headers: 33 | Alt-Svc: 34 | - h3=":443"; ma=86400 35 | Cache-Control: 36 | - no-store 37 | Cf-Cache-Status: 38 | - DYNAMIC 39 | Content-Type: 40 | - application/json; charset=utf-8 41 | Etag: 42 | - W/"210-+ISnhlSrm6Gd7LWWbqn3eOeSXhw" 43 | Server: 44 | - cloudflare 45 | Vary: 46 | - Accept-Encoding 47 | Via: 48 | - 1.1 google 49 | status: 200 OK 50 | code: 200 51 | duration: 0s 52 | ``` -------------------------------------------------------------------------------- /testdata/fixtures/resource_TeamResourceHandler_Fetch By ID.yaml: -------------------------------------------------------------------------------- ```yaml 1 | --- 2 | version: 2 3 | interactions: 4 | - id: 0 5 | request: 6 | proto: HTTP/1.1 7 | proto_major: 1 8 | proto_minor: 1 9 | content_length: 310 10 | transfer_encoding: [] 11 | trailer: {} 12 | host: api.linear.app 13 | remote_addr: "" 14 | request_uri: "" 15 | body: '{"query":"\n\t\tquery GetTeams($filter: TeamFilter) {\n\t\t\tteams(filter: $filter) {\n\t\t\t\tnodes {\n\t\t\t\t\tid\n\t\t\t\t\tname\n\t\t\t\t\tkey\n\t\t\t\t\tdescription\n\t\t\t\t\tstates {\n\t\t\t\t\t\tnodes {\n\t\t\t\t\t\t\tid\n\t\t\t\t\t\t\tname\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t"}' 16 | form: {} 17 | headers: 18 | Content-Type: 19 | - application/json 20 | url: https://api.linear.app/graphql 21 | method: POST 22 | response: 23 | proto: HTTP/2.0 24 | proto_major: 2 25 | proto_minor: 0 26 | transfer_encoding: [] 27 | trailer: {} 28 | content_length: -1 29 | uncompressed: true 30 | body: | 31 | {"data":{"teams":{"nodes":[{"id":"234c5451-a839-4c8f-98d9-da00973f1060","name":"Test Team","key":"TEST","description":null,"states":{"nodes":[{"id":"d4caa373-1a02-431c-bd3f-1bbb67318617","name":"Done"},{"id":"cffb8999-f10e-447d-9672-8faf5b06ac67","name":"Todo"},{"id":"42f7ad15-fca3-4d11-b349-0e3c1385c256","name":"Backlog"},{"id":"2d26ea57-c1f7-43ae-ba30-3f828ac8edb6","name":"Canceled"},{"id":"2a939ee1-65a1-445c-8e5d-18239e5f64bc","name":"Duplicate"},{"id":"12bb7f66-d9be-4faa-800f-49b8e3b38a3f","name":"In Progress"}]}}]}}} 32 | headers: 33 | Alt-Svc: 34 | - h3=":443"; ma=86400 35 | Cache-Control: 36 | - no-store 37 | Cf-Cache-Status: 38 | - DYNAMIC 39 | Content-Type: 40 | - application/json; charset=utf-8 41 | Etag: 42 | - W/"210-+ISnhlSrm6Gd7LWWbqn3eOeSXhw" 43 | Server: 44 | - cloudflare 45 | Vary: 46 | - Accept-Encoding 47 | Via: 48 | - 1.1 google 49 | status: 200 OK 50 | code: 200 51 | duration: 0s 52 | ``` -------------------------------------------------------------------------------- /testdata/fixtures/resource_TeamResourceHandler_Invalid ID.yaml: -------------------------------------------------------------------------------- ```yaml 1 | --- 2 | version: 2 3 | interactions: 4 | - id: 0 5 | request: 6 | proto: HTTP/1.1 7 | proto_major: 1 8 | proto_minor: 1 9 | content_length: 310 10 | transfer_encoding: [] 11 | trailer: {} 12 | host: api.linear.app 13 | remote_addr: "" 14 | request_uri: "" 15 | body: '{"query":"\n\t\tquery GetTeams($filter: TeamFilter) {\n\t\t\tteams(filter: $filter) {\n\t\t\t\tnodes {\n\t\t\t\t\tid\n\t\t\t\t\tname\n\t\t\t\t\tkey\n\t\t\t\t\tdescription\n\t\t\t\t\tstates {\n\t\t\t\t\t\tnodes {\n\t\t\t\t\t\t\tid\n\t\t\t\t\t\t\tname\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t"}' 16 | form: {} 17 | headers: 18 | Content-Type: 19 | - application/json 20 | url: https://api.linear.app/graphql 21 | method: POST 22 | response: 23 | proto: HTTP/2.0 24 | proto_major: 2 25 | proto_minor: 0 26 | transfer_encoding: [] 27 | trailer: {} 28 | content_length: -1 29 | uncompressed: true 30 | body: | 31 | {"data":{"teams":{"nodes":[{"id":"234c5451-a839-4c8f-98d9-da00973f1060","name":"Test Team","key":"TEST","description":null,"states":{"nodes":[{"id":"d4caa373-1a02-431c-bd3f-1bbb67318617","name":"Done"},{"id":"cffb8999-f10e-447d-9672-8faf5b06ac67","name":"Todo"},{"id":"42f7ad15-fca3-4d11-b349-0e3c1385c256","name":"Backlog"},{"id":"2d26ea57-c1f7-43ae-ba30-3f828ac8edb6","name":"Canceled"},{"id":"2a939ee1-65a1-445c-8e5d-18239e5f64bc","name":"Duplicate"},{"id":"12bb7f66-d9be-4faa-800f-49b8e3b38a3f","name":"In Progress"}]}}]}}} 32 | headers: 33 | Alt-Svc: 34 | - h3=":443"; ma=86400 35 | Cache-Control: 36 | - no-store 37 | Cf-Cache-Status: 38 | - DYNAMIC 39 | Content-Type: 40 | - application/json; charset=utf-8 41 | Etag: 42 | - W/"210-+ISnhlSrm6Gd7LWWbqn3eOeSXhw" 43 | Server: 44 | - cloudflare 45 | Vary: 46 | - Accept-Encoding 47 | Via: 48 | - 1.1 google 49 | status: 200 OK 50 | code: 200 51 | duration: 0s 52 | ``` -------------------------------------------------------------------------------- /testdata/fixtures/resource_TeamsResourceHandler_List All.yaml: -------------------------------------------------------------------------------- ```yaml 1 | --- 2 | version: 2 3 | interactions: 4 | - id: 0 5 | request: 6 | proto: HTTP/1.1 7 | proto_major: 1 8 | proto_minor: 1 9 | content_length: 310 10 | transfer_encoding: [] 11 | trailer: {} 12 | host: api.linear.app 13 | remote_addr: "" 14 | request_uri: "" 15 | body: '{"query":"\n\t\tquery GetTeams($filter: TeamFilter) {\n\t\t\tteams(filter: $filter) {\n\t\t\t\tnodes {\n\t\t\t\t\tid\n\t\t\t\t\tname\n\t\t\t\t\tkey\n\t\t\t\t\tdescription\n\t\t\t\t\tstates {\n\t\t\t\t\t\tnodes {\n\t\t\t\t\t\t\tid\n\t\t\t\t\t\t\tname\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t"}' 16 | form: {} 17 | headers: 18 | Content-Type: 19 | - application/json 20 | url: https://api.linear.app/graphql 21 | method: POST 22 | response: 23 | proto: HTTP/2.0 24 | proto_major: 2 25 | proto_minor: 0 26 | transfer_encoding: [] 27 | trailer: {} 28 | content_length: -1 29 | uncompressed: true 30 | body: | 31 | {"data":{"teams":{"nodes":[{"id":"234c5451-a839-4c8f-98d9-da00973f1060","name":"Test Team","key":"TEST","description":null,"states":{"nodes":[{"id":"d4caa373-1a02-431c-bd3f-1bbb67318617","name":"Done"},{"id":"cffb8999-f10e-447d-9672-8faf5b06ac67","name":"Todo"},{"id":"42f7ad15-fca3-4d11-b349-0e3c1385c256","name":"Backlog"},{"id":"2d26ea57-c1f7-43ae-ba30-3f828ac8edb6","name":"Canceled"},{"id":"2a939ee1-65a1-445c-8e5d-18239e5f64bc","name":"Duplicate"},{"id":"12bb7f66-d9be-4faa-800f-49b8e3b38a3f","name":"In Progress"}]}}]}}} 32 | headers: 33 | Alt-Svc: 34 | - h3=":443"; ma=86400 35 | Cache-Control: 36 | - no-store 37 | Cf-Cache-Status: 38 | - DYNAMIC 39 | Content-Type: 40 | - application/json; charset=utf-8 41 | Etag: 42 | - W/"210-+ISnhlSrm6Gd7LWWbqn3eOeSXhw" 43 | Server: 44 | - cloudflare 45 | Vary: 46 | - Accept-Encoding 47 | Via: 48 | - 1.1 google 49 | status: 200 OK 50 | code: 200 51 | duration: 0s 52 | ``` -------------------------------------------------------------------------------- /testdata/fixtures/create_project_handler_Invalid team ID.yaml: -------------------------------------------------------------------------------- ```yaml 1 | --- 2 | version: 2 3 | interactions: 4 | - id: 0 5 | request: 6 | proto: HTTP/1.1 7 | proto_major: 1 8 | proto_minor: 1 9 | content_length: 357 10 | transfer_encoding: [] 11 | trailer: {} 12 | host: api.linear.app 13 | remote_addr: "" 14 | request_uri: "" 15 | body: '{"query":"\n\t\tmutation ProjectCreate($input: ProjectCreateInput!) {\n\t\t\tprojectCreate(input: $input) {\n\t\t\t\tsuccess\n\t\t\t\tproject {\n\t\t\t\t\tid\n\t\t\t\t\tname\n\t\t\t\t\tdescription\n\t\t\t\t\tslugId\n\t\t\t\t\tstate\n\t\t\t\t\turl\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t","variables":{"input":{"name":"Test Project 3","teamIds":["invalid-team-id"]}}}' 16 | form: {} 17 | headers: 18 | Content-Type: 19 | - application/json 20 | url: https://api.linear.app/graphql 21 | method: POST 22 | response: 23 | proto: HTTP/2.0 24 | proto_major: 2 25 | proto_minor: 0 26 | transfer_encoding: [] 27 | trailer: {} 28 | content_length: -1 29 | uncompressed: true 30 | body: | 31 | {"errors":[{"message":"Argument Validation Error","path":["projectCreate"],"locations":[{"line":3,"column":4}],"extensions":{"code":"INVALID_INPUT","validationErrors":[{"target":{"name":"Test Project 3","teamIds":["invalid-team-id"]},"value":["invalid-team-id"],"property":"teamIds","children":[],"constraints":{"isUuid":"each value in teamIds must be a UUID"}}],"type":"invalid input","userError":true,"userPresentableMessage":"each value in teamIds must be a UUID."}}],"data":null} 32 | headers: 33 | Alt-Svc: 34 | - h3=":443"; ma=86400 35 | Cache-Control: 36 | - no-store 37 | Cf-Cache-Status: 38 | - DYNAMIC 39 | Content-Type: 40 | - application/json; charset=utf-8 41 | Etag: 42 | - W/"1e4-JRWXeyaRzkdssN5PwkmdFPPwZ6Y" 43 | Server: 44 | - cloudflare 45 | Vary: 46 | - Accept-Encoding 47 | Via: 48 | - 1.1 google 49 | status: 200 OK 50 | code: 200 51 | duration: 0s 52 | ``` -------------------------------------------------------------------------------- /testdata/fixtures/get_teams_handler_Get Teams.yaml: -------------------------------------------------------------------------------- ```yaml 1 | --- 2 | version: 2 3 | interactions: 4 | - id: 0 5 | request: 6 | proto: HTTP/1.1 7 | proto_major: 1 8 | proto_minor: 1 9 | content_length: 367 10 | transfer_encoding: [] 11 | trailer: {} 12 | host: api.linear.app 13 | remote_addr: "" 14 | request_uri: "" 15 | body: '{"query":"\n\t\tquery GetTeams($filter: TeamFilter) {\n\t\t\tteams(filter: $filter) {\n\t\t\t\tnodes {\n\t\t\t\t\tid\n\t\t\t\t\tname\n\t\t\t\t\tkey\n\t\t\t\t\tdescription\n\t\t\t\t\tstates {\n\t\t\t\t\t\tnodes {\n\t\t\t\t\t\t\tid\n\t\t\t\t\t\t\tname\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t","variables":{"filter":{"name":{"contains":"Test Team"}}}}' 16 | form: {} 17 | headers: 18 | Content-Type: 19 | - application/json 20 | url: https://api.linear.app/graphql 21 | method: POST 22 | response: 23 | proto: HTTP/2.0 24 | proto_major: 2 25 | proto_minor: 0 26 | transfer_encoding: [] 27 | trailer: {} 28 | content_length: -1 29 | uncompressed: true 30 | body: | 31 | {"data":{"teams":{"nodes":[{"id":"234c5451-a839-4c8f-98d9-da00973f1060","name":"Test Team","key":"TEST","description":null,"states":{"nodes":[{"id":"d4caa373-1a02-431c-bd3f-1bbb67318617","name":"Done"},{"id":"cffb8999-f10e-447d-9672-8faf5b06ac67","name":"Todo"},{"id":"42f7ad15-fca3-4d11-b349-0e3c1385c256","name":"Backlog"},{"id":"2d26ea57-c1f7-43ae-ba30-3f828ac8edb6","name":"Canceled"},{"id":"2a939ee1-65a1-445c-8e5d-18239e5f64bc","name":"Duplicate"},{"id":"12bb7f66-d9be-4faa-800f-49b8e3b38a3f","name":"In Progress"}]}}]}}} 32 | headers: 33 | Alt-Svc: 34 | - h3=":443"; ma=86400 35 | Cache-Control: 36 | - no-store 37 | Cf-Cache-Status: 38 | - DYNAMIC 39 | Content-Type: 40 | - application/json; charset=utf-8 41 | Etag: 42 | - W/"210-+ISnhlSrm6Gd7LWWbqn3eOeSXhw" 43 | Server: 44 | - cloudflare 45 | Vary: 46 | - Accept-Encoding 47 | Via: 48 | - 1.1 google 49 | status: 200 OK 50 | code: 200 51 | duration: 0s 52 | ``` -------------------------------------------------------------------------------- /testdata/fixtures/create_milestone_handler_Invalid project ID.yaml: -------------------------------------------------------------------------------- ```yaml 1 | --- 2 | version: 2 3 | interactions: 4 | - id: 0 5 | request: 6 | proto: HTTP/1.1 7 | proto_major: 1 8 | proto_minor: 1 9 | content_length: 440 10 | transfer_encoding: [] 11 | trailer: {} 12 | host: api.linear.app 13 | remote_addr: "" 14 | request_uri: "" 15 | body: '{"query":"\n\t\tmutation ProjectMilestoneCreate($input: ProjectMilestoneCreateInput!) {\n\t\t\tprojectMilestoneCreate(input: $input) {\n\t\t\t\tsuccess\n\t\t\t\tprojectMilestone {\n\t\t\t\t\tid\n\t\t\t\t\tname\n\t\t\t\t\tdescription\n\t\t\t\t\ttargetDate\n\t\t\t\t\tproject {\n\t\t\t\t\t\tid\n\t\t\t\t\t\tname\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t","variables":{"input":{"name":"Test Milestone 3.1","projectId":"invalid-project-id"}}}' 16 | form: {} 17 | headers: 18 | Content-Type: 19 | - application/json 20 | url: https://api.linear.app/graphql 21 | method: POST 22 | response: 23 | proto: HTTP/2.0 24 | proto_major: 2 25 | proto_minor: 0 26 | transfer_encoding: [] 27 | trailer: {} 28 | content_length: -1 29 | uncompressed: true 30 | body: | 31 | {"errors":[{"message":"Argument Validation Error","path":["projectMilestoneCreate"],"locations":[{"line":3,"column":4}],"extensions":{"code":"INVALID_INPUT","validationErrors":[{"target":{"name":"Test Milestone 3.1","projectId":"invalid-project-id"},"value":"invalid-project-id","property":"projectId","children":[],"constraints":{"isUuid":"projectId must be a UUID"}}],"type":"invalid input","userError":true,"userPresentableMessage":"projectId must be a UUID."}}],"data":null} 32 | headers: 33 | Alt-Svc: 34 | - h3=":443"; ma=86400 35 | Cache-Control: 36 | - no-store 37 | Cf-Cache-Status: 38 | - DYNAMIC 39 | Content-Type: 40 | - application/json; charset=utf-8 41 | Etag: 42 | - W/"1df-TEVUGn11CxiIlKsKcBOVc2L/nlo" 43 | Server: 44 | - cloudflare 45 | Vary: 46 | - Accept-Encoding 47 | Via: 48 | - 1.1 google 49 | status: 200 OK 50 | code: 200 51 | duration: 0s 52 | ``` -------------------------------------------------------------------------------- /pkg/linear/test_helpers.go: -------------------------------------------------------------------------------- ```go 1 | package linear 2 | 3 | import ( 4 | "os" 5 | "strings" 6 | "testing" 7 | 8 | "gopkg.in/dnaeon/go-vcr.v4/pkg/cassette" 9 | "gopkg.in/dnaeon/go-vcr.v4/pkg/recorder" 10 | ) 11 | 12 | // NewTestClient creates a LinearClient for testing 13 | // If record is true, it will record HTTP interactions 14 | // If record is false, it will replay recorded interactions 15 | func NewTestClient(t *testing.T, cassetteName string, record bool) (*LinearClient, func()) { 16 | if record { 17 | // Ensure API key is set when recording 18 | if os.Getenv("TEST_LINEAR_API_KEY") == "" { 19 | t.Fatal("TEST_LINEAR_API_KEY environment variable is required for recording") 20 | } 21 | } 22 | 23 | wipeAuthorizationHook := func(i *cassette.Interaction) error { 24 | delete(i.Request.Headers, "Authorization") 25 | delete(i.Response.Headers, "Set-Cookie") 26 | return nil 27 | } 28 | 29 | wipeChangingMetadataHook := func(i *cassette.Interaction) error { 30 | delete(i.Request.Headers, "User-Agent") 31 | 32 | delete(i.Response.Headers, "Cf-Ray") 33 | delete(i.Response.Headers, "Date") 34 | 35 | for k := range i.Response.Headers { 36 | if strings.HasPrefix(strings.ToLower(k), "x-") { 37 | delete(i.Response.Headers, k) 38 | } 39 | } 40 | i.Response.Duration = 0 41 | return nil 42 | } 43 | 44 | // Create the recorder with appropriate mode 45 | options := []recorder.Option{ 46 | // don't record authorization header in cassettes 47 | recorder.WithHook(wipeAuthorizationHook, recorder.AfterCaptureHook), 48 | recorder.WithHook(wipeChangingMetadataHook, recorder.AfterCaptureHook), 49 | recorder.WithMatcher(cassette.NewDefaultMatcher(cassette.WithIgnoreAuthorization(), cassette.WithIgnoreUserAgent())), 50 | } 51 | if record { 52 | options = append(options, recorder.WithMode(recorder.ModeRecordOnly)) 53 | } else { 54 | options = append(options, recorder.WithMode(recorder.ModeReplayOnly)) 55 | } 56 | 57 | r, err := recorder.New("../../testdata/fixtures/"+cassetteName, options...) 58 | if err != nil { 59 | t.Fatalf("Failed to create recorder: %v", err) 60 | } 61 | 62 | // Create a Linear client that uses the recorder's HTTP client 63 | apiKey := os.Getenv("TEST_LINEAR_API_KEY") 64 | client := &LinearClient{ 65 | apiKey: apiKey, 66 | httpClient: r.GetDefaultClient(), 67 | rateLimiter: NewRateLimiter(1400), 68 | } 69 | 70 | // Return the client and a cleanup function 71 | cleanup := func() { 72 | r.Stop() 73 | } 74 | 75 | return client, cleanup 76 | } 77 | ``` -------------------------------------------------------------------------------- /testdata/fixtures/update_comment_handler_Valid comment update.yaml: -------------------------------------------------------------------------------- ```yaml 1 | --- 2 | version: 2 3 | interactions: 4 | - id: 0 5 | request: 6 | proto: HTTP/1.1 7 | proto_major: 1 8 | proto_minor: 1 9 | content_length: 536 10 | transfer_encoding: [] 11 | trailer: {} 12 | host: api.linear.app 13 | remote_addr: "" 14 | request_uri: "" 15 | body: '{"query":"\n\t\tmutation UpdateComment($id: String!, $input: CommentUpdateInput!) {\n\t\t\tcommentUpdate(id: $id, input: $input) {\n\t\t\t\tsuccess\n\t\t\t\tcomment {\n\t\t\t\t\tid\n\t\t\t\t\tbody\n\t\t\t\t\turl\n\t\t\t\t\tcreatedAt\n\t\t\t\t\tuser {\n\t\t\t\t\t\tid\n\t\t\t\t\t\tname\n\t\t\t\t\t}\n\t\t\t\t\tissue {\n\t\t\t\t\t\tid\n\t\t\t\t\t\tidentifier\n\t\t\t\t\t\ttitle\n\t\t\t\t\t\turl\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t","variables":{"id":"ae3d62d6-3f40-4990-867b-5c97dd265a40","input":{"body":"Updated comment text"}}}' 16 | form: {} 17 | headers: 18 | Content-Type: 19 | - application/json 20 | url: https://api.linear.app/graphql 21 | method: POST 22 | response: 23 | proto: HTTP/2.0 24 | proto_major: 2 25 | proto_minor: 0 26 | transfer_encoding: [] 27 | trailer: {} 28 | content_length: -1 29 | uncompressed: true 30 | body: | 31 | {"data":{"commentUpdate":{"success":true,"comment":{"id":"ae3d62d6-3f40-4990-867b-5c97dd265a40","body":"Updated comment text","url":"https://linear.app/linear-mcp-go-test/issue/TEST-10/updated-test-issue#comment-ae3d62d6","createdAt":"2025-03-30T13:37:20.666Z","user":{"id":"cc24eee4-9edc-4bfe-b91b-fedde125ba85","name":"Gero Leinemann"},"issue":{"id":"1c2de93f-4321-4015-bfde-ee893ef7976f","identifier":"TEST-10","title":"Updated Test Issue","url":"https://linear.app/linear-mcp-go-test/issue/TEST-10/updated-test-issue"}}}}} 32 | headers: 33 | Alt-Svc: 34 | - h3=":443"; ma=86400 35 | Cache-Control: 36 | - no-store 37 | Cf-Cache-Status: 38 | - DYNAMIC 39 | Content-Type: 40 | - application/json; charset=utf-8 41 | Etag: 42 | - W/"20f-QmoB3WjGuI3Kn4PiWkTFI7/hRj8" 43 | Server: 44 | - cloudflare 45 | Vary: 46 | - Accept-Encoding 47 | Via: 48 | - 1.1 google 49 | status: 200 OK 50 | code: 200 51 | duration: 0s 52 | ``` -------------------------------------------------------------------------------- /pkg/tools/update_issue_comment.go: -------------------------------------------------------------------------------- ```go 1 | package tools 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/geropl/linear-mcp-go/pkg/linear" 8 | "github.com/mark3labs/mcp-go/mcp" 9 | ) 10 | 11 | // UpdateCommentTool is the tool definition for updating a comment 12 | var UpdateCommentTool = mcp.NewTool("linear_update_issue_comment", 13 | mcp.WithDescription("Updates an existing comment on a Linear issue."), 14 | mcp.WithString("comment", mcp.Required(), mcp.Description("Comment identifier to update. Accepts: full URL, UUID, shorthand (comment-abc123), or hash (abc123).")), 15 | mcp.WithString("body", mcp.Required(), mcp.Description("New comment text in markdown format")), 16 | ) 17 | 18 | // UpdateCommentHandler handles the linear_update_comment tool 19 | func UpdateCommentHandler(linearClient *linear.LinearClient) func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { 20 | return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { 21 | // Extract arguments 22 | commentIdentifier, err := request.RequireString("comment") 23 | if err != nil { 24 | return &mcp.CallToolResult{IsError: true, Content: []mcp.Content{mcp.TextContent{Type: "text", Text: err.Error()}}}, nil 25 | } 26 | 27 | // Resolve comment identifier to a UUID 28 | commentID, err := resolveCommentIdentifier(linearClient, commentIdentifier) 29 | if err != nil { 30 | return &mcp.CallToolResult{IsError: true, Content: []mcp.Content{mcp.TextContent{Type: "text", Text: fmt.Sprintf("Failed to resolve comment: %v", err)}}}, nil 31 | } 32 | 33 | body, err := request.RequireString("body") 34 | if err != nil { 35 | return &mcp.CallToolResult{IsError: true, Content: []mcp.Content{mcp.TextContent{Type: "text", Text: err.Error()}}}, nil 36 | } 37 | 38 | // Update the comment 39 | input := linear.UpdateCommentInput{ 40 | CommentID: commentID, 41 | Body: body, 42 | } 43 | 44 | comment, issue, err := linearClient.UpdateComment(input) 45 | if err != nil { 46 | return &mcp.CallToolResult{IsError: true, Content: []mcp.Content{mcp.TextContent{Type: "text", Text: fmt.Sprintf("Failed to update comment: %v", err)}}}, nil 47 | } 48 | 49 | // Return the result 50 | resultText := fmt.Sprintf("Updated comment on %s\n", formatIssueIdentifier(issue)) 51 | resultText += fmt.Sprintf("Comment ID: %s\n", comment.ID) 52 | resultText += fmt.Sprintf("Thread (for replies): %s\n", comment.ID) 53 | resultText += fmt.Sprintf("URL: %s", comment.URL) 54 | return &mcp.CallToolResult{Content: []mcp.Content{mcp.TextContent{Type: "text", Text: resultText}}}, nil 55 | } 56 | } 57 | ``` -------------------------------------------------------------------------------- /testdata/fixtures/create_issue_handler_Valid issue with teamId.yaml: -------------------------------------------------------------------------------- ```yaml 1 | --- 2 | version: 2 3 | interactions: 4 | - id: 0 5 | request: 6 | proto: HTTP/1.1 7 | proto_major: 1 8 | proto_minor: 1 9 | content_length: 686 10 | transfer_encoding: [] 11 | trailer: {} 12 | host: api.linear.app 13 | remote_addr: "" 14 | request_uri: "" 15 | body: '{"query":"\n\t\tmutation CreateIssue($input: IssueCreateInput!) {\n\t\t\tissueCreate(input: $input) {\n\t\t\t\tsuccess\n\t\t\t\tissue {\n\t\t\t\t\tid\n\t\t\t\t\tidentifier\n\t\t\t\t\ttitle\n\t\t\t\t\tdescription\n\t\t\t\t\tpriority\n\t\t\t\t\turl\n\t\t\t\t\tcreatedAt\n\t\t\t\t\tupdatedAt\n\t\t\t\t\tstate {\n\t\t\t\t\t\tid\n\t\t\t\t\t\tname\n\t\t\t\t\t}\n\t\t\t\t\tteam {\n\t\t\t\t\t\tid\n\t\t\t\t\t\tname\n\t\t\t\t\t\tkey\n\t\t\t\t\t}\n\t\t\t\t\tlabels {\n\t\t\t\t\t\tnodes {\n\t\t\t\t\t\t\tid\n\t\t\t\t\t\t\tname\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t","variables":{"input":{"description":"","teamId":"234c5451-a839-4c8f-98d9-da00973f1060","title":"Test Issue"}}}' 16 | form: {} 17 | headers: 18 | Content-Type: 19 | - application/json 20 | User-Agent: 21 | - linear-mcp-go/1.0.0 22 | url: https://api.linear.app/graphql 23 | method: POST 24 | response: 25 | proto: HTTP/2.0 26 | proto_major: 2 27 | proto_minor: 0 28 | transfer_encoding: [] 29 | trailer: {} 30 | content_length: -1 31 | uncompressed: true 32 | body: | 33 | {"data":{"issueCreate":{"success":true,"issue":{"id":"71daa141-706f-45bd-af79-fa27571b9974","identifier":"TEST-48","title":"Test Issue","description":null,"priority":0,"url":"https://linear.app/linear-mcp-go-test/issue/TEST-48/test-issue","createdAt":"2025-03-30T09:34:52.655Z","updatedAt":"2025-03-30T09:34:52.655Z","state":{"id":"42f7ad15-fca3-4d11-b349-0e3c1385c256","name":"Backlog"},"team":{"id":"234c5451-a839-4c8f-98d9-da00973f1060","name":"Test Team","key":"TEST"},"labels":{"nodes":[]}}}}} 34 | headers: 35 | Alt-Svc: 36 | - h3=":443"; ma=86400 37 | Cache-Control: 38 | - no-store 39 | Cf-Cache-Status: 40 | - DYNAMIC 41 | Content-Type: 42 | - application/json; charset=utf-8 43 | Etag: 44 | - W/"1f3-Uv10fBIw9AkcaiDdPamGO+YgS4U" 45 | Server: 46 | - cloudflare 47 | Vary: 48 | - Accept-Encoding 49 | Via: 50 | - 1.1 google 51 | status: 200 OK 52 | code: 200 53 | duration: 0s 54 | ``` -------------------------------------------------------------------------------- /testdata/fixtures/create_issue_handler_Valid issue with team.yaml: -------------------------------------------------------------------------------- ```yaml 1 | --- 2 | version: 2 3 | interactions: 4 | - id: 0 5 | request: 6 | proto: HTTP/1.1 7 | proto_major: 1 8 | proto_minor: 1 9 | content_length: 831 10 | transfer_encoding: [] 11 | trailer: {} 12 | host: api.linear.app 13 | remote_addr: "" 14 | request_uri: "" 15 | body: '{"query":"\n\t\tmutation CreateIssue($input: IssueCreateInput!) {\n\t\t\tissueCreate(input: $input) {\n\t\t\t\tsuccess\n\t\t\t\tissue {\n\t\t\t\t\tid\n\t\t\t\t\tidentifier\n\t\t\t\t\ttitle\n\t\t\t\t\tdescription\n\t\t\t\t\tpriority\n\t\t\t\t\turl\n\t\t\t\t\tcreatedAt\n\t\t\t\t\tupdatedAt\n\t\t\t\t\tstate {\n\t\t\t\t\t\tid\n\t\t\t\t\t\tname\n\t\t\t\t\t}\n\t\t\t\t\tteam {\n\t\t\t\t\t\tid\n\t\t\t\t\t\tname\n\t\t\t\t\t\tkey\n\t\t\t\t\t}\n\t\t\t\t\tlabels {\n\t\t\t\t\t\tnodes {\n\t\t\t\t\t\t\tid\n\t\t\t\t\t\t\tname\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\tproject {\n\t\t\t\t\t\tid\n\t\t\t\t\t\tname\n\t\t\t\t\t}\n\t\t\t\t\tprojectMilestone {\n\t\t\t\t\t\tid\n\t\t\t\t\t\tname\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t","variables":{"input":{"description":"","teamId":"234c5451-a839-4c8f-98d9-da00973f1060","title":"Test Issue"}}}' 16 | form: {} 17 | headers: 18 | Content-Type: 19 | - application/json 20 | url: https://api.linear.app/graphql 21 | method: POST 22 | response: 23 | proto: HTTP/2.0 24 | proto_major: 2 25 | proto_minor: 0 26 | transfer_encoding: [] 27 | trailer: {} 28 | content_length: -1 29 | uncompressed: true 30 | body: | 31 | {"data":{"issueCreate":{"success":true,"issue":{"id":"9e842dfe-d72f-4d32-a2a3-330338d1cabc","identifier":"TEST-85","title":"Test Issue","description":null,"priority":0,"url":"https://linear.app/linear-mcp-go-test/issue/TEST-85/test-issue","createdAt":"2025-10-06T09:44:03.705Z","updatedAt":"2025-10-06T09:44:03.705Z","state":{"id":"42f7ad15-fca3-4d11-b349-0e3c1385c256","name":"Backlog"},"team":{"id":"234c5451-a839-4c8f-98d9-da00973f1060","name":"Test Team","key":"TEST"},"labels":{"nodes":[]},"project":null,"projectMilestone":null}}}} 32 | headers: 33 | Alt-Svc: 34 | - h3=":443"; ma=86400 35 | Cache-Control: 36 | - no-store 37 | Cf-Cache-Status: 38 | - DYNAMIC 39 | Content-Type: 40 | - application/json; charset=utf-8 41 | Etag: 42 | - W/"21a-riLwqmexbAhReQeBEf18rQ70w9Y" 43 | Server: 44 | - cloudflare 45 | Vary: 46 | - Accept-Encoding 47 | Via: 48 | - 1.1 google 49 | status: 200 OK 50 | code: 200 51 | duration: 0s 52 | ``` -------------------------------------------------------------------------------- /memory-bank/productContext.md: -------------------------------------------------------------------------------- ```markdown 1 | # Product Context: Linear MCP Server 2 | 3 | ## Why This Project Exists 4 | The Linear MCP Server exists to bridge the gap between AI assistants and Linear, a popular issue tracking and project management tool. By implementing the Model Context Protocol (MCP), this server enables AI assistants to interact with Linear's API in a standardized way, allowing them to create, update, and manage issues without requiring custom integration code for each assistant. 5 | 6 | ## Problems It Solves 7 | 1. **Integration Complexity**: Simplifies the process of connecting AI assistants to Linear by providing a standardized interface. 8 | 2. **API Consistency**: Abstracts away the complexities of the Linear API, providing a consistent experience. 9 | 3. **Rate Limiting**: Handles Linear's API rate limits automatically, preventing quota exhaustion. 10 | 4. **Authentication Management**: Manages API key authentication in a secure manner. 11 | 5. **Error Handling**: Provides meaningful error messages when operations fail. 12 | 13 | ## How It Should Work 14 | 1. **Server Initialization**: 15 | - The server starts and listens for MCP requests on stdin/stdout. 16 | - It validates the LINEAR_API_KEY environment variable. 17 | - It registers all available tools with the MCP server. 18 | 19 | 2. **Tool Execution**: 20 | - When a tool is called (e.g., linear_create_issue), the server validates the input parameters. 21 | - It translates the request into appropriate Linear API calls. 22 | - It handles the response, formatting it according to MCP specifications. 23 | - It returns the result to the caller. 24 | 25 | 3. **Error Scenarios**: 26 | - If the API key is missing or invalid, it returns a clear error message. 27 | - If required parameters are missing, it returns parameter validation errors. 28 | - If the Linear API returns an error, it translates and returns it in a user-friendly format. 29 | - If rate limits are exceeded, it handles backoff and retries appropriately. 30 | 31 | ## User Experience Goals 32 | 1. **Simplicity**: Users should be able to set up and use the server with minimal configuration. 33 | 2. **Reliability**: The server should handle errors gracefully and provide clear feedback. 34 | 3. **Completeness**: All common Linear operations should be supported. 35 | 4. **Performance**: Operations should be efficient and respect API rate limits. 36 | 5. **Documentation**: Clear documentation should be provided for all tools and setup procedures. 37 | 38 | ## Integration Points 39 | 1. **Linear API**: The server interacts with Linear's API to perform operations. 40 | 2. **MCP Protocol**: The server implements the MCP protocol to communicate with AI assistants. 41 | 3. **Environment**: The server uses environment variables for configuration. 42 | ``` -------------------------------------------------------------------------------- /testdata/fixtures/search_projects_handler_Multiple results.yaml: -------------------------------------------------------------------------------- ```yaml 1 | --- 2 | version: 2 3 | interactions: 4 | - id: 0 5 | request: 6 | proto: HTTP/1.1 7 | proto_major: 1 8 | proto_minor: 1 9 | content_length: 541 10 | transfer_encoding: [] 11 | trailer: {} 12 | host: api.linear.app 13 | remote_addr: "" 14 | request_uri: "" 15 | body: '{"query":"\n\t\tquery SearchProjects($filter: ProjectFilter) {\n\t\t\tprojects(filter: $filter) {\n\t\t\t\tnodes {\n\t\t\t\t\tid\n\t\t\t\t\tname\n\t\t\t\t\tdescription\n\t\t\t\t\tslugId\n\t\t\t\t\tstate\n\t\t\t\t\turl\n\t\t\t\t\tinitiatives(first: 1) {\n\t\t\t\t\t\tnodes {\n\t\t\t\t\t\t\tid\n\t\t\t\t\t\t\tname\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\tlead {\n\t\t\t\t\t\tid\n\t\t\t\t\t\tname\n\t\t\t\t\t}\n\t\t\t\t\tstartDate\n\t\t\t\t\ttargetDate\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t","variables":{"filter":{"name":{"containsIgnoreCase":"MCP"}}}}' 16 | form: {} 17 | headers: 18 | Content-Type: 19 | - application/json 20 | url: https://api.linear.app/graphql 21 | method: POST 22 | response: 23 | proto: HTTP/2.0 24 | proto_major: 2 25 | proto_minor: 0 26 | transfer_encoding: [] 27 | trailer: {} 28 | content_length: -1 29 | uncompressed: true 30 | body: | 31 | {"data":{"projects":{"nodes":[{"id":"473d62ae-38fe-4439-9007-08763e51bf88","name":"Totally different MCP project with no content","description":"Summary goes here","slugId":"29129640a673","state":"backlog","url":"https://linear.app/linear-mcp-go-test/project/totally-different-mcp-project-with-no-content-29129640a673","initiatives":{"nodes":[]},"lead":null,"startDate":null,"targetDate":null},{"id":"01bff2dd-ab7f-4464-b425-97073862013f","name":"MCP tool investigation","description":"Summary text goes here","slugId":"ae44897e42a7","state":"backlog","url":"https://linear.app/linear-mcp-go-test/project/mcp-tool-investigation-ae44897e42a7","initiatives":{"nodes":[{"id":"15e7c1bd-c0c5-4801-ac9a-8e98bf88ea7a","name":"Push for MCP"}]},"lead":{"id":"cc24eee4-9edc-4bfe-b91b-fedde125ba85","name":"Gero Leinemann"},"startDate":"2025-06-02","targetDate":"2025-06-30"}]}}} 32 | headers: 33 | Alt-Svc: 34 | - h3=":443"; ma=86400 35 | Cache-Control: 36 | - no-store 37 | Cf-Cache-Status: 38 | - DYNAMIC 39 | Content-Type: 40 | - application/json; charset=utf-8 41 | Etag: 42 | - W/"365-vV3AiEsYIfWAgp5Ebe03XF87/Kw" 43 | Server: 44 | - cloudflare 45 | Vary: 46 | - Accept-Encoding 47 | Via: 48 | - 1.1 google 49 | status: 200 OK 50 | code: 200 51 | duration: 0s 52 | ``` -------------------------------------------------------------------------------- /testdata/fixtures/create_issue_handler_Valid issue with team UUID.yaml: -------------------------------------------------------------------------------- ```yaml 1 | --- 2 | version: 2 3 | interactions: 4 | - id: 0 5 | request: 6 | proto: HTTP/1.1 7 | proto_major: 1 8 | proto_minor: 1 9 | content_length: 846 10 | transfer_encoding: [] 11 | trailer: {} 12 | host: api.linear.app 13 | remote_addr: "" 14 | request_uri: "" 15 | body: '{"query":"\n\t\tmutation CreateIssue($input: IssueCreateInput!) {\n\t\t\tissueCreate(input: $input) {\n\t\t\t\tsuccess\n\t\t\t\tissue {\n\t\t\t\t\tid\n\t\t\t\t\tidentifier\n\t\t\t\t\ttitle\n\t\t\t\t\tdescription\n\t\t\t\t\tpriority\n\t\t\t\t\turl\n\t\t\t\t\tcreatedAt\n\t\t\t\t\tupdatedAt\n\t\t\t\t\tstate {\n\t\t\t\t\t\tid\n\t\t\t\t\t\tname\n\t\t\t\t\t}\n\t\t\t\t\tteam {\n\t\t\t\t\t\tid\n\t\t\t\t\t\tname\n\t\t\t\t\t\tkey\n\t\t\t\t\t}\n\t\t\t\t\tlabels {\n\t\t\t\t\t\tnodes {\n\t\t\t\t\t\t\tid\n\t\t\t\t\t\t\tname\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\tproject {\n\t\t\t\t\t\tid\n\t\t\t\t\t\tname\n\t\t\t\t\t}\n\t\t\t\t\tprojectMilestone {\n\t\t\t\t\t\tid\n\t\t\t\t\t\tname\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t","variables":{"input":{"description":"","teamId":"234c5451-a839-4c8f-98d9-da00973f1060","title":"Test Issue with team UUID"}}}' 16 | form: {} 17 | headers: 18 | Content-Type: 19 | - application/json 20 | url: https://api.linear.app/graphql 21 | method: POST 22 | response: 23 | proto: HTTP/2.0 24 | proto_major: 2 25 | proto_minor: 0 26 | transfer_encoding: [] 27 | trailer: {} 28 | content_length: -1 29 | uncompressed: true 30 | body: | 31 | {"data":{"issueCreate":{"success":true,"issue":{"id":"73667bc8-f31c-41e7-a513-ed5b196cb25e","identifier":"TEST-86","title":"Test Issue with team UUID","description":null,"priority":0,"url":"https://linear.app/linear-mcp-go-test/issue/TEST-86/test-issue-with-team-uuid","createdAt":"2025-10-06T09:44:08.669Z","updatedAt":"2025-10-06T09:44:08.669Z","state":{"id":"42f7ad15-fca3-4d11-b349-0e3c1385c256","name":"Backlog"},"team":{"id":"234c5451-a839-4c8f-98d9-da00973f1060","name":"Test Team","key":"TEST"},"labels":{"nodes":[]},"project":null,"projectMilestone":null}}}} 32 | headers: 33 | Alt-Svc: 34 | - h3=":443"; ma=86400 35 | Cache-Control: 36 | - no-store 37 | Cf-Cache-Status: 38 | - DYNAMIC 39 | Content-Type: 40 | - application/json; charset=utf-8 41 | Etag: 42 | - W/"238-LjhPlWjTFDB/d+FI0JgaJxLeSPw" 43 | Server: 44 | - cloudflare 45 | Vary: 46 | - Accept-Encoding 47 | Via: 48 | - 1.1 google 49 | status: 200 OK 50 | code: 200 51 | duration: 0s 52 | ``` -------------------------------------------------------------------------------- /testdata/fixtures/create_issue_handler_Create sub issue.yaml: -------------------------------------------------------------------------------- ```yaml 1 | --- 2 | version: 2 3 | interactions: 4 | - id: 0 5 | request: 6 | proto: HTTP/1.1 7 | proto_major: 1 8 | proto_minor: 1 9 | content_length: 880 10 | transfer_encoding: [] 11 | trailer: {} 12 | host: api.linear.app 13 | remote_addr: "" 14 | request_uri: "" 15 | body: '{"query":"\n\t\tmutation CreateIssue($input: IssueCreateInput!) {\n\t\t\tissueCreate(input: $input) {\n\t\t\t\tsuccess\n\t\t\t\tissue {\n\t\t\t\t\tid\n\t\t\t\t\tidentifier\n\t\t\t\t\ttitle\n\t\t\t\t\tdescription\n\t\t\t\t\tpriority\n\t\t\t\t\turl\n\t\t\t\t\tcreatedAt\n\t\t\t\t\tupdatedAt\n\t\t\t\t\tstate {\n\t\t\t\t\t\tid\n\t\t\t\t\t\tname\n\t\t\t\t\t}\n\t\t\t\t\tteam {\n\t\t\t\t\t\tid\n\t\t\t\t\t\tname\n\t\t\t\t\t\tkey\n\t\t\t\t\t}\n\t\t\t\t\tlabels {\n\t\t\t\t\t\tnodes {\n\t\t\t\t\t\t\tid\n\t\t\t\t\t\t\tname\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\tproject {\n\t\t\t\t\t\tid\n\t\t\t\t\t\tname\n\t\t\t\t\t}\n\t\t\t\t\tprojectMilestone {\n\t\t\t\t\t\tid\n\t\t\t\t\t\tname\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t","variables":{"input":{"description":"","parentId":"1c2de93f-4321-4015-bfde-ee893ef7976f","teamId":"234c5451-a839-4c8f-98d9-da00973f1060","title":"Sub Issue"}}}' 16 | form: {} 17 | headers: 18 | Content-Type: 19 | - application/json 20 | url: https://api.linear.app/graphql 21 | method: POST 22 | response: 23 | proto: HTTP/2.0 24 | proto_major: 2 25 | proto_minor: 0 26 | transfer_encoding: [] 27 | trailer: {} 28 | content_length: -1 29 | uncompressed: true 30 | body: | 31 | {"data":{"issueCreate":{"success":true,"issue":{"id":"507ac2f7-10d3-4566-bf85-01e761b8aacc","identifier":"TEST-89","title":"Sub Issue","description":null,"priority":0,"url":"https://linear.app/linear-mcp-go-test/issue/TEST-89/sub-issue","createdAt":"2025-10-06T09:44:26.768Z","updatedAt":"2025-10-06T09:44:26.768Z","state":{"id":"42f7ad15-fca3-4d11-b349-0e3c1385c256","name":"Backlog"},"team":{"id":"234c5451-a839-4c8f-98d9-da00973f1060","name":"Test Team","key":"TEST"},"labels":{"nodes":[]},"project":null,"projectMilestone":null}}}} 32 | headers: 33 | Alt-Svc: 34 | - h3=":443"; ma=86400 35 | Cache-Control: 36 | - no-store 37 | Cf-Cache-Status: 38 | - DYNAMIC 39 | Content-Type: 40 | - application/json; charset=utf-8 41 | Etag: 42 | - W/"218-Ctac8rozKoI8dD6f8c2wIWoJnE4" 43 | Server: 44 | - cloudflare 45 | Vary: 46 | - Accept-Encoding 47 | Via: 48 | - 1.1 google 49 | status: 200 OK 50 | code: 200 51 | duration: 0s 52 | ``` -------------------------------------------------------------------------------- /pkg/tools/get_user_issues.go: -------------------------------------------------------------------------------- ```go 1 | package tools 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/geropl/linear-mcp-go/pkg/linear" 8 | "github.com/mark3labs/mcp-go/mcp" 9 | ) 10 | 11 | // GetUserIssuesTool is the tool definition for getting user issues 12 | var GetUserIssuesTool = mcp.NewTool("linear_get_user_issues", 13 | mcp.WithDescription("Retrieves issues assigned to a user."), 14 | mcp.WithString("user", mcp.Description("Optional user identifier (UUID, name, or email). If not provided, returns authenticated user's issues")), 15 | mcp.WithBoolean("includeArchived", mcp.Description("Include archived issues in results")), 16 | mcp.WithNumber("limit", mcp.Description("Maximum number of issues to return (default: 50)")), 17 | ) 18 | 19 | // GetUserIssuesHandler handles the linear_get_user_issues tool 20 | func GetUserIssuesHandler(linearClient *linear.LinearClient) func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { 21 | return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { 22 | // Build input 23 | input := linear.GetUserIssuesInput{} 24 | 25 | if user, err := request.RequireString("user"); err == nil && user != "" { 26 | // Resolve user identifier to a user ID 27 | userID, err := resolveUserIdentifier(linearClient, user) 28 | if err != nil { 29 | return &mcp.CallToolResult{IsError: true, Content: []mcp.Content{mcp.TextContent{Type: "text", Text: fmt.Sprintf("Failed to resolve user: %v", err)}}}, nil 30 | } 31 | input.UserID = userID 32 | } 33 | 34 | input.IncludeArchived = request.GetBool("includeArchived", false) 35 | input.Limit = request.GetInt("limit", 50) 36 | 37 | // Get user issues 38 | issues, err := linearClient.GetUserIssues(input) 39 | if err != nil { 40 | return &mcp.CallToolResult{IsError: true, Content: []mcp.Content{mcp.TextContent{Type: "text", Text: fmt.Sprintf("Failed to get user issues: %v", err)}}}, nil 41 | } 42 | 43 | // Format the result 44 | resultText := fmt.Sprintf("Found %d issues:\n", len(issues)) 45 | for _, issue := range issues { 46 | // Create a temporary Issue object to use with formatIssueIdentifier 47 | tempIssue := &linear.Issue{ 48 | ID: issue.ID, 49 | Identifier: issue.Identifier, 50 | } 51 | 52 | statusStr := "None" 53 | if issue.Status != "" { 54 | statusStr = issue.Status 55 | } else if issue.StateName != "" { 56 | statusStr = issue.StateName 57 | } 58 | 59 | resultText += fmt.Sprintf("- %s\n", formatIssueIdentifier(tempIssue)) 60 | resultText += fmt.Sprintf(" Title: %s\n", issue.Title) 61 | resultText += fmt.Sprintf(" Priority: %s\n", priorityToString(issue.Priority)) 62 | resultText += fmt.Sprintf(" Status: %s\n", statusStr) 63 | resultText += fmt.Sprintf(" URL: %s\n", issue.URL) 64 | } 65 | 66 | return &mcp.CallToolResult{Content: []mcp.Content{mcp.TextContent{Type: "text", Text: resultText}}}, nil 67 | } 68 | } 69 | ``` -------------------------------------------------------------------------------- /testdata/fixtures/create_issue_handler_Create issue with project ID.yaml: -------------------------------------------------------------------------------- ```yaml 1 | --- 2 | version: 2 3 | interactions: 4 | - id: 0 5 | request: 6 | proto: HTTP/1.1 7 | proto_major: 1 8 | proto_minor: 1 9 | content_length: 893 10 | transfer_encoding: [] 11 | trailer: {} 12 | host: api.linear.app 13 | remote_addr: "" 14 | request_uri: "" 15 | body: '{"query":"\n\t\tmutation CreateIssue($input: IssueCreateInput!) {\n\t\t\tissueCreate(input: $input) {\n\t\t\t\tsuccess\n\t\t\t\tissue {\n\t\t\t\t\tid\n\t\t\t\t\tidentifier\n\t\t\t\t\ttitle\n\t\t\t\t\tdescription\n\t\t\t\t\tpriority\n\t\t\t\t\turl\n\t\t\t\t\tcreatedAt\n\t\t\t\t\tupdatedAt\n\t\t\t\t\tstate {\n\t\t\t\t\t\tid\n\t\t\t\t\t\tname\n\t\t\t\t\t}\n\t\t\t\t\tteam {\n\t\t\t\t\t\tid\n\t\t\t\t\t\tname\n\t\t\t\t\t\tkey\n\t\t\t\t\t}\n\t\t\t\t\tlabels {\n\t\t\t\t\t\tnodes {\n\t\t\t\t\t\t\tid\n\t\t\t\t\t\t\tname\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\tproject {\n\t\t\t\t\t\tid\n\t\t\t\t\t\tname\n\t\t\t\t\t}\n\t\t\t\t\tprojectMilestone {\n\t\t\t\t\t\tid\n\t\t\t\t\t\tname\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t","variables":{"input":{"description":"","projectId":"01bff2dd-ab7f-4464-b425-97073862013f","teamId":"234c5451-a839-4c8f-98d9-da00973f1060","title":"Issue with Project ID"}}}' 16 | form: {} 17 | headers: 18 | Content-Type: 19 | - application/json 20 | url: https://api.linear.app/graphql 21 | method: POST 22 | response: 23 | proto: HTTP/2.0 24 | proto_major: 2 25 | proto_minor: 0 26 | transfer_encoding: [] 27 | trailer: {} 28 | content_length: -1 29 | uncompressed: true 30 | body: | 31 | {"data":{"issueCreate":{"success":true,"issue":{"id":"5015a1d4-b382-4962-b8be-aa434b6b496d","identifier":"TEST-93","title":"Issue with Project ID","description":null,"priority":0,"url":"https://linear.app/linear-mcp-go-test/issue/TEST-93/issue-with-project-id","createdAt":"2025-10-06T09:44:46.740Z","updatedAt":"2025-10-06T09:44:46.740Z","state":{"id":"42f7ad15-fca3-4d11-b349-0e3c1385c256","name":"Backlog"},"team":{"id":"234c5451-a839-4c8f-98d9-da00973f1060","name":"Test Team","key":"TEST"},"labels":{"nodes":[]},"project":{"id":"01bff2dd-ab7f-4464-b425-97073862013f","name":"MCP tool investigation"},"projectMilestone":null}}}} 32 | headers: 33 | Alt-Svc: 34 | - h3=":443"; ma=86400 35 | Cache-Control: 36 | - no-store 37 | Cf-Cache-Status: 38 | - DYNAMIC 39 | Content-Type: 40 | - application/json; charset=utf-8 41 | Etag: 42 | - W/"279-SjgXENxrlDWv72SYhs5pyB6uKnI" 43 | Server: 44 | - cloudflare 45 | Vary: 46 | - Accept-Encoding 47 | Via: 48 | - 1.1 google 49 | status: 200 OK 50 | code: 200 51 | duration: 0s 52 | ``` -------------------------------------------------------------------------------- /testdata/fixtures/get_project_handler_By ID.yaml: -------------------------------------------------------------------------------- ```yaml 1 | --- 2 | version: 2 3 | interactions: 4 | - id: 0 5 | request: 6 | proto: HTTP/1.1 7 | proto_major: 1 8 | proto_minor: 1 9 | content_length: 733 10 | transfer_encoding: [] 11 | trailer: {} 12 | host: api.linear.app 13 | remote_addr: "" 14 | request_uri: "" 15 | body: '{"query":"\n\t\tquery GetProject($id: String!) {\n\t\t\tproject(id: $id) {\n\t\t\t\tid\n\t\t\t\tname\n\t\t\t\tdescription\n\t\t\t\tslugId\n\t\t\t\tstate\n\t\t\t\turl\n\t\t\t\tcreatedAt\n\t\t\t\tupdatedAt\n\t\t\t\tlead {\n\t\t\t\t\tid\n\t\t\t\t\tname\n\t\t\t\t\temail\n\t\t\t\t}\n\t\t\t\tmembers {\n\t\t\t\t\tnodes {\n\t\t\t\t\t\tid\n\t\t\t\t\t\tname\n\t\t\t\t\t\temail\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tteams {\n\t\t\t\t\tnodes {\n\t\t\t\t\t\tid\n\t\t\t\t\t\tname\n\t\t\t\t\t\tkey\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tinitiatives(first: 10) {\n\t\t\t\t\tnodes {\n\t\t\t\t\t\tid\n\t\t\t\t\t\tname\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tstartDate\n\t\t\t\ttargetDate\n\t\t\t}\n\t\t}\n\t","variables":{"id":"01bff2dd-ab7f-4464-b425-97073862013f"}}' 16 | form: {} 17 | headers: 18 | Content-Type: 19 | - application/json 20 | url: https://api.linear.app/graphql 21 | method: POST 22 | response: 23 | proto: HTTP/2.0 24 | proto_major: 2 25 | proto_minor: 0 26 | transfer_encoding: [] 27 | trailer: {} 28 | content_length: -1 29 | uncompressed: true 30 | body: | 31 | {"data":{"project":{"id":"01bff2dd-ab7f-4464-b425-97073862013f","name":"MCP tool investigation","description":"Summary text goes here","slugId":"ae44897e42a7","state":"backlog","url":"https://linear.app/linear-mcp-go-test/project/mcp-tool-investigation-ae44897e42a7","createdAt":"2025-06-28T18:06:47.606Z","updatedAt":"2025-06-28T18:07:51.899Z","lead":{"id":"cc24eee4-9edc-4bfe-b91b-fedde125ba85","name":"Gero Leinemann","email":"[email protected]"},"members":{"nodes":[{"id":"cc24eee4-9edc-4bfe-b91b-fedde125ba85","name":"Gero Leinemann","email":"[email protected]"}]},"teams":{"nodes":[{"id":"234c5451-a839-4c8f-98d9-da00973f1060","name":"Test Team","key":"TEST"}]},"initiatives":{"nodes":[{"id":"15e7c1bd-c0c5-4801-ac9a-8e98bf88ea7a","name":"Push for MCP"}]},"startDate":"2025-06-02","targetDate":"2025-06-30"}}} 32 | headers: 33 | Alt-Svc: 34 | - h3=":443"; ma=86400 35 | Cache-Control: 36 | - no-store 37 | Cf-Cache-Status: 38 | - DYNAMIC 39 | Content-Type: 40 | - application/json; charset=utf-8 41 | Etag: 42 | - W/"348-ZpF/ZpfaL+aW46aIruU9iQ4HbXY" 43 | Server: 44 | - cloudflare 45 | Vary: 46 | - Accept-Encoding 47 | Via: 48 | - 1.1 google 49 | status: 200 OK 50 | code: 200 51 | duration: 0s 52 | ``` -------------------------------------------------------------------------------- /pkg/tools/reply_to_comment.go: -------------------------------------------------------------------------------- ```go 1 | package tools 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/geropl/linear-mcp-go/pkg/linear" 8 | "github.com/mark3labs/mcp-go/mcp" 9 | ) 10 | 11 | // ReplyToCommentTool is a specialized tool for replying to comments 12 | var ReplyToCommentTool = mcp.NewTool("linear_reply_to_comment", 13 | mcp.WithDescription("Reply to an existing comment on a Linear issue."), 14 | mcp.WithString("thread", mcp.Required(), mcp.Description("Comment to reply to. Accepts: full URL, UUID, shorthand (comment-abc123), or hash (abc123).")), 15 | mcp.WithString("body", mcp.Required(), mcp.Description("Reply text in markdown format")), 16 | mcp.WithString("createAsUser", mcp.Description("Optional custom username to show for the reply")), 17 | ) 18 | 19 | // ReplyToCommentHandler handles the linear_reply_to_comment tool 20 | func ReplyToCommentHandler(linearClient *linear.LinearClient) func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { 21 | return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { 22 | // Extract arguments 23 | threadIdentifier, err := request.RequireString("thread") 24 | if err != nil { 25 | return &mcp.CallToolResult{IsError: true, Content: []mcp.Content{mcp.TextContent{Type: "text", Text: err.Error()}}}, nil 26 | } 27 | 28 | body, err := request.RequireString("body") 29 | if err != nil { 30 | return &mcp.CallToolResult{IsError: true, Content: []mcp.Content{mcp.TextContent{Type: "text", Text: err.Error()}}}, nil 31 | } 32 | 33 | createAsUser := request.GetString("createAsUser", "") 34 | 35 | // Resolve the parent comment to get its UUID 36 | parentCommentID, err := resolveCommentIdentifier(linearClient, threadIdentifier) 37 | if err != nil { 38 | return &mcp.CallToolResult{IsError: true, Content: []mcp.Content{mcp.TextContent{Type: "text", Text: fmt.Sprintf("Failed to resolve comment: %v", err)}}}, nil 39 | } 40 | 41 | // Get the parent comment to find its issue 42 | parentComment, err := linearClient.GetComment(parentCommentID) 43 | if err != nil { 44 | return &mcp.CallToolResult{IsError: true, Content: []mcp.Content{mcp.TextContent{Type: "text", Text: fmt.Sprintf("Failed to get parent comment: %v", err)}}}, nil 45 | } 46 | 47 | if parentComment.Issue == nil { 48 | return &mcp.CallToolResult{IsError: true, Content: []mcp.Content{mcp.TextContent{Type: "text", Text: "Parent comment does not have an associated issue"}}}, nil 49 | } 50 | 51 | // Add the reply 52 | input := linear.AddCommentInput{ 53 | IssueID: parentComment.Issue.ID, 54 | Body: body, 55 | CreateAsUser: createAsUser, 56 | ParentID: parentCommentID, 57 | } 58 | 59 | comment, issue, err := linearClient.AddComment(input) 60 | if err != nil { 61 | return &mcp.CallToolResult{IsError: true, Content: []mcp.Content{mcp.TextContent{Type: "text", Text: fmt.Sprintf("Failed to add reply: %v", err)}}}, nil 62 | } 63 | 64 | // Return the result using the unified format 65 | resultText := formatNewComment(comment, issue, parentCommentID) 66 | return &mcp.CallToolResult{Content: []mcp.Content{mcp.TextContent{Type: "text", Text: resultText}}}, nil 67 | } 68 | } 69 | ``` -------------------------------------------------------------------------------- /testdata/fixtures/create_issue_handler_Valid issue.yaml: -------------------------------------------------------------------------------- ```yaml 1 | --- 2 | version: 2 3 | interactions: 4 | - id: 0 5 | request: 6 | proto: HTTP/1.1 7 | proto_major: 1 8 | proto_minor: 1 9 | content_length: 579 10 | transfer_encoding: [] 11 | trailer: {} 12 | host: api.linear.app 13 | remote_addr: "" 14 | request_uri: "" 15 | body: '{"query":"\n\t\tmutation CreateIssue($input: IssueCreateInput!) {\n\t\t\tissueCreate(input: $input) {\n\t\t\t\tsuccess\n\t\t\t\tissue {\n\t\t\t\t\tid\n\t\t\t\t\tidentifier\n\t\t\t\t\ttitle\n\t\t\t\t\tdescription\n\t\t\t\t\tpriority\n\t\t\t\t\turl\n\t\t\t\t\tcreatedAt\n\t\t\t\t\tupdatedAt\n\t\t\t\t\tstate {\n\t\t\t\t\t\tid\n\t\t\t\t\t\tname\n\t\t\t\t\t}\n\t\t\t\t\tteam {\n\t\t\t\t\t\tid\n\t\t\t\t\t\tname\n\t\t\t\t\t\tkey\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t","variables":{"input":{"description":"","teamId":"234c5451-a839-4c8f-98d9-da00973f1060","title":"Test Issue"}}}' 16 | form: {} 17 | headers: 18 | Content-Type: 19 | - application/json 20 | User-Agent: 21 | - linear-mcp-go/1.0.0 22 | url: https://api.linear.app/graphql 23 | method: POST 24 | response: 25 | proto: HTTP/2.0 26 | proto_major: 2 27 | proto_minor: 0 28 | transfer_encoding: [] 29 | trailer: {} 30 | content_length: -1 31 | uncompressed: true 32 | body: | 33 | {"data":{"issueCreate":{"success":true,"issue":{"id":"1c2de93f-4321-4015-bfde-ee893ef7976f","identifier":"TEST-10","title":"Test Issue","description":null,"priority":0,"url":"https://linear.app/linear-mcp-go-test/issue/TEST-10/test-issue","createdAt":"2025-03-03T11:34:49.241Z","updatedAt":"2025-03-03T11:34:49.241Z","state":{"id":"42f7ad15-fca3-4d11-b349-0e3c1385c256","name":"Backlog"},"team":{"id":"234c5451-a839-4c8f-98d9-da00973f1060","name":"Test Team","key":"TEST"}}}}} 34 | headers: 35 | Alt-Svc: 36 | - h3=":443"; ma=86400 37 | Cache-Control: 38 | - no-store 39 | Cf-Cache-Status: 40 | - DYNAMIC 41 | Cf-Ray: 42 | - 91a8d3ad484f8fee-FRA 43 | Content-Type: 44 | - application/json; charset=utf-8 45 | Date: 46 | - Mon, 03 Mar 2025 11:34:49 GMT 47 | Etag: 48 | - W/"1dd-e9YqnIA3F4HsF8LOEx21H1J0EIg" 49 | Server: 50 | - cloudflare 51 | Vary: 52 | - Accept-Encoding 53 | Via: 54 | - 1.1 google 55 | X-Complexity: 56 | - "6" 57 | X-Ratelimit-Complexity-Limit: 58 | - "3000000" 59 | X-Ratelimit-Complexity-Remaining: 60 | - "2996924" 61 | X-Ratelimit-Complexity-Reset: 62 | - "1741005289233" 63 | X-Ratelimit-Requests-Limit: 64 | - "1500" 65 | X-Ratelimit-Requests-Remaining: 66 | - "1498" 67 | X-Ratelimit-Requests-Reset: 68 | - "1741005289233" 69 | X-Request-Id: 70 | - 91a8d3ad73e58fee-FRA 71 | status: 200 OK 72 | code: 200 73 | duration: 166.601029ms 74 | ``` -------------------------------------------------------------------------------- /pkg/server/test_helpers.go: -------------------------------------------------------------------------------- ```go 1 | package server 2 | 3 | import ( 4 | "flag" 5 | "os" 6 | "path/filepath" 7 | "testing" 8 | 9 | "gopkg.in/yaml.v3" 10 | ) 11 | 12 | var record = flag.Bool("record", false, "Record HTTP interactions (excluding writes)") 13 | var recordWrites = flag.Bool("recordWrites", false, "Record HTTP interactions (incl. writes)") 14 | var golden = flag.Bool("golden", false, "Update all golden files and recordings") 15 | 16 | // Shared constants for tests 17 | const ( 18 | TEAM_NAME = "Test Team" 19 | TEAM_KEY = "TEST" 20 | TEAM_ID = "234c5451-a839-4c8f-98d9-da00973f1060" 21 | ISSUE_ID = "TEST-10" 22 | COMMENT_ISSUE_ID = "TEST-12" // Used for testing add_comment handler 23 | USER_ID = "cc24eee4-9edc-4bfe-b91b-fedde125ba85" 24 | PROJECT_ID = "01bff2dd-ab7f-4464-b425-97073862013f" 25 | UPDATE_PROJECT_ID = "bfa49864-16c9-44db-994e-a11ba2b386f1" 26 | MILESTONE_ID = "c86acc00-3035-4a67-82f2-2a5bf6453e92" 27 | UPDATE_MILESTONE_ID = "2d95299d-1341-484b-ab00-5cb587f2cc67" 28 | INITIATIVE_ID = "3bb752a7-897e-4240-9306-01e48872fab3" 29 | UPDATE_INITIATIVE_ID = "c6a7dd0c-cbe2-4101-906d-ddd97acb2241" 30 | ) 31 | 32 | // expectation defines the expected output and error for a test case 33 | // For resource tests, Output will store the JSON representation of []mcp.ResourceContents 34 | type expectation struct { 35 | Err string `yaml:"err"` // Empty string means no error expected 36 | Output string `yaml:"output", flow` // Expected complete output 37 | } 38 | 39 | // readGoldenFile reads an expectation from a golden file 40 | func readGoldenFile(t *testing.T, path string) expectation { 41 | t.Helper() 42 | 43 | // Check if the golden file exists 44 | if _, err := os.Stat(path); os.IsNotExist(err) { 45 | // If the file doesn't exist, return an empty expectation 46 | // This allows tests to pass initially when golden files are missing, 47 | // prompting the user to run with -golden* flags to create them. 48 | t.Logf("Golden file %s does not exist. Run with appropriate -golden* flag to create it.", path) 49 | return expectation{} 50 | } 51 | 52 | // Read the golden file 53 | data, err := os.ReadFile(path) 54 | if err != nil { 55 | t.Fatalf("Failed to read golden file %s: %v", path, err) 56 | } 57 | 58 | // Parse the golden file 59 | var exp expectation 60 | if err := yaml.Unmarshal(data, &exp); err != nil { 61 | // If unmarshalling fails, treat it as an empty expectation 62 | // This handles cases where the golden file might be corrupted or empty 63 | t.Logf("Failed to parse golden file %s: %v. Treating as empty.", path, err) 64 | return expectation{} 65 | } 66 | 67 | return exp 68 | } 69 | 70 | // writeGoldenFile writes an expectation to a golden file 71 | func writeGoldenFile(t *testing.T, path string, exp expectation) { 72 | t.Helper() 73 | 74 | // Create the directory if it doesn't exist 75 | dir := filepath.Dir(path) 76 | if err := os.MkdirAll(dir, 0755); err != nil { 77 | t.Fatalf("Failed to create directory %s: %v", dir, err) 78 | } 79 | 80 | // Marshal the YAML node 81 | data, err := yaml.Marshal(&exp) 82 | if err != nil { 83 | t.Fatalf("Failed to marshal expectation: %v", err) 84 | } 85 | 86 | // Write the golden file 87 | if err := os.WriteFile(path, data, 0644); err != nil { 88 | t.Fatalf("Failed to write golden file %s: %v", path, err) 89 | } 90 | t.Logf("Successfully wrote golden file: %s", path) 91 | } 92 | ``` -------------------------------------------------------------------------------- /pkg/tools/add_comment.go: -------------------------------------------------------------------------------- ```go 1 | package tools 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/geropl/linear-mcp-go/pkg/linear" 8 | "github.com/mark3labs/mcp-go/mcp" 9 | ) 10 | 11 | // AddCommentTool is the tool definition for adding a comment 12 | var AddCommentTool = mcp.NewTool("linear_add_comment", 13 | mcp.WithDescription("Add/post a comment to a Linear issue. To reply to an existing comment, use linear_get_issue_comments to get the comment identifier or URL, then pass it in 'thread'."), 14 | mcp.WithString("issue", mcp.Required(), mcp.Description("ID or identifier (e.g., 'TEAM-123') of the issue to comment on")), 15 | mcp.WithString("thread", mcp.Description("Optional comment identifier to reply to. Accepts: full Linear comment URL, UUID, shorthand (comment-abc123), or hash (abc123). Creates a threaded reply.")), 16 | mcp.WithString("body", mcp.Required(), mcp.Description("Comment text in markdown format")), 17 | mcp.WithString("createAsUser", mcp.Description("Optional custom username to show for the comment")), 18 | ) 19 | 20 | // AddCommentHandler handles the linear_add_comment tool 21 | func AddCommentHandler(linearClient *linear.LinearClient) func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { 22 | return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { 23 | // Extract arguments 24 | issueIdentifier, err := request.RequireString("issue") 25 | if err != nil { 26 | return &mcp.CallToolResult{IsError: true, Content: []mcp.Content{mcp.TextContent{Type: "text", Text: err.Error()}}}, nil 27 | } 28 | 29 | // Resolve issue identifier to a UUID 30 | issueID, err := resolveIssueIdentifier(linearClient, issueIdentifier) 31 | if err != nil { 32 | return &mcp.CallToolResult{IsError: true, Content: []mcp.Content{mcp.TextContent{Type: "text", Text: fmt.Sprintf("Failed to resolve issue: %v", err)}}}, nil 33 | } 34 | 35 | body, err := request.RequireString("body") 36 | if err != nil { 37 | return &mcp.CallToolResult{IsError: true, Content: []mcp.Content{mcp.TextContent{Type: "text", Text: err.Error()}}}, nil 38 | } 39 | 40 | // Extract optional arguments 41 | createAsUser := request.GetString("createAsUser", "") 42 | threadIdentifier := request.GetString("thread", "") 43 | 44 | // Resolve thread identifier to UUID if provided 45 | var parentID string 46 | if threadIdentifier != "" { 47 | resolvedParentID, err := resolveCommentIdentifier(linearClient, threadIdentifier) 48 | if err != nil { 49 | return &mcp.CallToolResult{IsError: true, Content: []mcp.Content{mcp.TextContent{Type: "text", Text: fmt.Sprintf("Failed to resolve thread comment: %v", err)}}}, nil 50 | } 51 | parentID = resolvedParentID 52 | } 53 | 54 | // Add the comment 55 | input := linear.AddCommentInput{ 56 | IssueID: issueID, 57 | Body: body, 58 | CreateAsUser: createAsUser, 59 | ParentID: parentID, 60 | } 61 | 62 | comment, issue, err := linearClient.AddComment(input) 63 | if err != nil { 64 | return &mcp.CallToolResult{IsError: true, Content: []mcp.Content{mcp.TextContent{Type: "text", Text: fmt.Sprintf("Failed to add comment: %v", err)}}}, nil 65 | } 66 | 67 | // Return the result using the unified format 68 | resultText := formatNewComment(comment, issue, parentID) 69 | return &mcp.CallToolResult{Content: []mcp.Content{mcp.TextContent{Type: "text", Text: resultText}}}, nil 70 | } 71 | } 72 | ``` -------------------------------------------------------------------------------- /testdata/fixtures/get_user_issues_handler_Specific user issues.yaml: -------------------------------------------------------------------------------- ```yaml 1 | --- 2 | version: 2 3 | interactions: 4 | - id: 0 5 | request: 6 | proto: HTTP/1.1 7 | proto_major: 1 8 | proto_minor: 1 9 | content_length: 556 10 | transfer_encoding: [] 11 | trailer: {} 12 | host: api.linear.app 13 | remote_addr: "" 14 | request_uri: "" 15 | body: '{"query":"\n\t\tquery GetUserIssues($userId: String!, $first: Int, $includeArchived: Boolean) {\n\t\t\tuser(id: $userId) {\n\t\t\t\tassignedIssues(first: $first, includeArchived: $includeArchived) {\n\t\t\t\t\tnodes {\n\t\t\t\t\t\tid\n\t\t\t\t\t\tidentifier\n\t\t\t\t\t\ttitle\n\t\t\t\t\t\tdescription\n\t\t\t\t\t\tpriority\n\t\t\t\t\t\turl\n\t\t\t\t\t\tstate {\n\t\t\t\t\t\t\tid\n\t\t\t\t\t\t\tname\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t","variables":{"first":5,"includeArchived":false,"userId":"cc24eee4-9edc-4bfe-b91b-fedde125ba85"}}' 16 | form: {} 17 | headers: 18 | Content-Type: 19 | - application/json 20 | url: https://api.linear.app/graphql 21 | method: POST 22 | response: 23 | proto: HTTP/2.0 24 | proto_major: 2 25 | proto_minor: 0 26 | transfer_encoding: [] 27 | trailer: {} 28 | content_length: -1 29 | uncompressed: true 30 | body: "{\"data\":{\"user\":{\"assignedIssues\":{\"nodes\":[{\"id\":\"1c2de93f-4321-4015-bfde-ee893ef7976f\",\"identifier\":\"TEST-10\",\"title\":\"Updated Test Issue\",\"description\":null,\"priority\":0,\"url\":\"https://linear.app/linear-mcp-go-test/issue/TEST-10/updated-test-issue\",\"state\":{\"id\":\"42f7ad15-fca3-4d11-b349-0e3c1385c256\",\"name\":\"Backlog\"}},{\"id\":\"c58953c5-a31d-4c5a-9427-6d6ebd9a1a4e\",\"identifier\":\"TEST-1\",\"title\":\"Welcome to Linear \U0001F44B\",\"description\":\"Hi there. Complete these issues to learn how to use Linear and discover ✨**ProTips.** When you're done, delete them or move them to another team for others to view.\\n\\n### **To start, type** `C` to **create your first issue.**\\n\\nCreate issues from any view using `C` or by clicking the `New issue` button.\\n\\n \\n\\n[1189b618-97f2-4e2c-ae25-4f25467679e7](https://uploads.linear.app/fe63b3e2-bf87-46c0-8784-cd7d639287c8/532d146d-bcd6-4602-bf1f-83f674b70fff/1189b618-97f2-4e2c-ae25-4f25467679e7)\\n\\nOur issue editor and comments support Markdown. You can also: \\n\\n* @mention a teammate\\n* Drag & drop images or video (Loom & YouTube embed automatically)\\n* Use emoji ✅\\n* Type `/` to bring up more formatting options\",\"priority\":2,\"url\":\"https://linear.app/linear-mcp-go-test/issue/TEST-1/welcome-to-linear\",\"state\":{\"id\":\"cffb8999-f10e-447d-9672-8faf5b06ac67\",\"name\":\"Todo\"}}]}}}}\n" 31 | headers: 32 | Alt-Svc: 33 | - h3=":443"; ma=86400 34 | Cache-Control: 35 | - no-store 36 | Cf-Cache-Status: 37 | - DYNAMIC 38 | Content-Type: 39 | - application/json; charset=utf-8 40 | Etag: 41 | - W/"52b-iwXdi0Au8LYVFWrptXXwpSz7HVA" 42 | Server: 43 | - cloudflare 44 | Vary: 45 | - Accept-Encoding 46 | Via: 47 | - 1.1 google 48 | status: 200 OK 49 | code: 200 50 | duration: 0s 51 | ``` -------------------------------------------------------------------------------- /testdata/fixtures/get_initiative_handler_Non-existent name.yaml: -------------------------------------------------------------------------------- ```yaml 1 | --- 2 | version: 2 3 | interactions: 4 | - id: 0 5 | request: 6 | proto: HTTP/1.1 7 | proto_major: 1 8 | proto_minor: 1 9 | content_length: 201 10 | transfer_encoding: [] 11 | trailer: {} 12 | host: api.linear.app 13 | remote_addr: "" 14 | request_uri: "" 15 | body: '{"query":"\n\t\tquery GetInitiative($id: String!) {\n\t\t\tinitiative(id: $id) {\n\t\t\t\tid\n\t\t\t\tname\n\t\t\t\tdescription\n\t\t\t\turl\n\t\t\t}\n\t\t}\n\t","variables":{"id":"non-existent-name"}}' 16 | form: {} 17 | headers: 18 | Content-Type: 19 | - application/json 20 | url: https://api.linear.app/graphql 21 | method: POST 22 | response: 23 | proto: HTTP/2.0 24 | proto_major: 2 25 | proto_minor: 0 26 | transfer_encoding: [] 27 | trailer: {} 28 | content_length: -1 29 | uncompressed: true 30 | body: | 31 | {"errors":[{"message":"Entity not found: Initiative","path":["initiative"],"locations":[{"line":3,"column":4}],"extensions":{"type":"invalid input","code":"INPUT_ERROR","statusCode":400,"userError":true,"userPresentableMessage":"Could not find referenced Initiative."}}],"data":null} 32 | headers: 33 | Alt-Svc: 34 | - h3=":443"; ma=86400 35 | Cache-Control: 36 | - no-store 37 | Cf-Cache-Status: 38 | - DYNAMIC 39 | Content-Type: 40 | - application/json; charset=utf-8 41 | Etag: 42 | - W/"11c-c44P18yu0Ek2xjOBk4Ycpqr5fPg" 43 | Server: 44 | - cloudflare 45 | Vary: 46 | - Accept-Encoding 47 | Via: 48 | - 1.1 google 49 | status: 200 OK 50 | code: 200 51 | duration: 0s 52 | - id: 1 53 | request: 54 | proto: HTTP/1.1 55 | proto_major: 1 56 | proto_minor: 1 57 | content_length: 295 58 | transfer_encoding: [] 59 | trailer: {} 60 | host: api.linear.app 61 | remote_addr: "" 62 | request_uri: "" 63 | body: '{"query":"\n\t\tquery GetInitiativeByName($filter: InitiativeFilter) {\n\t\t\tinitiatives(filter: $filter, first: 1) {\n\t\t\t\tnodes {\n\t\t\t\t\tid\n\t\t\t\t\tname\n\t\t\t\t\tdescription\n\t\t\t\t\turl\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t","variables":{"filter":{"name":{"eq":"non-existent-name"}}}}' 64 | form: {} 65 | headers: 66 | Content-Type: 67 | - application/json 68 | url: https://api.linear.app/graphql 69 | method: POST 70 | response: 71 | proto: HTTP/2.0 72 | proto_major: 2 73 | proto_minor: 0 74 | transfer_encoding: [] 75 | trailer: {} 76 | content_length: 38 77 | uncompressed: false 78 | body: | 79 | {"data":{"initiatives":{"nodes":[]}}} 80 | headers: 81 | Alt-Svc: 82 | - h3=":443"; ma=86400 83 | Cache-Control: 84 | - no-store 85 | Cf-Cache-Status: 86 | - DYNAMIC 87 | Content-Length: 88 | - "38" 89 | Content-Type: 90 | - application/json; charset=utf-8 91 | Etag: 92 | - W/"26-1+AHGSycMEc+rIWhJuNJZIAom5A" 93 | Server: 94 | - cloudflare 95 | Vary: 96 | - Accept-Encoding 97 | Via: 98 | - 1.1 google 99 | status: 200 OK 100 | code: 200 101 | duration: 0s 102 | ``` -------------------------------------------------------------------------------- /testdata/fixtures/update_initiative_handler_Non-existent initiative.yaml: -------------------------------------------------------------------------------- ```yaml 1 | --- 2 | version: 2 3 | interactions: 4 | - id: 0 5 | request: 6 | proto: HTTP/1.1 7 | proto_major: 1 8 | proto_minor: 1 9 | content_length: 207 10 | transfer_encoding: [] 11 | trailer: {} 12 | host: api.linear.app 13 | remote_addr: "" 14 | request_uri: "" 15 | body: '{"query":"\n\t\tquery GetInitiative($id: String!) {\n\t\t\tinitiative(id: $id) {\n\t\t\t\tid\n\t\t\t\tname\n\t\t\t\tdescription\n\t\t\t\turl\n\t\t\t}\n\t\t}\n\t","variables":{"id":"non-existent-initiative"}}' 16 | form: {} 17 | headers: 18 | Content-Type: 19 | - application/json 20 | url: https://api.linear.app/graphql 21 | method: POST 22 | response: 23 | proto: HTTP/2.0 24 | proto_major: 2 25 | proto_minor: 0 26 | transfer_encoding: [] 27 | trailer: {} 28 | content_length: -1 29 | uncompressed: true 30 | body: | 31 | {"errors":[{"message":"Entity not found: Initiative","path":["initiative"],"locations":[{"line":3,"column":4}],"extensions":{"type":"invalid input","code":"INPUT_ERROR","statusCode":400,"userError":true,"userPresentableMessage":"Could not find referenced Initiative."}}],"data":null} 32 | headers: 33 | Alt-Svc: 34 | - h3=":443"; ma=86400 35 | Cache-Control: 36 | - no-store 37 | Cf-Cache-Status: 38 | - DYNAMIC 39 | Content-Type: 40 | - application/json; charset=utf-8 41 | Etag: 42 | - W/"11c-c44P18yu0Ek2xjOBk4Ycpqr5fPg" 43 | Server: 44 | - cloudflare 45 | Vary: 46 | - Accept-Encoding 47 | Via: 48 | - 1.1 google 49 | status: 200 OK 50 | code: 200 51 | duration: 0s 52 | - id: 1 53 | request: 54 | proto: HTTP/1.1 55 | proto_major: 1 56 | proto_minor: 1 57 | content_length: 301 58 | transfer_encoding: [] 59 | trailer: {} 60 | host: api.linear.app 61 | remote_addr: "" 62 | request_uri: "" 63 | body: '{"query":"\n\t\tquery GetInitiativeByName($filter: InitiativeFilter) {\n\t\t\tinitiatives(filter: $filter, first: 1) {\n\t\t\t\tnodes {\n\t\t\t\t\tid\n\t\t\t\t\tname\n\t\t\t\t\tdescription\n\t\t\t\t\turl\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t","variables":{"filter":{"name":{"eq":"non-existent-initiative"}}}}' 64 | form: {} 65 | headers: 66 | Content-Type: 67 | - application/json 68 | url: https://api.linear.app/graphql 69 | method: POST 70 | response: 71 | proto: HTTP/2.0 72 | proto_major: 2 73 | proto_minor: 0 74 | transfer_encoding: [] 75 | trailer: {} 76 | content_length: 38 77 | uncompressed: false 78 | body: | 79 | {"data":{"initiatives":{"nodes":[]}}} 80 | headers: 81 | Alt-Svc: 82 | - h3=":443"; ma=86400 83 | Cache-Control: 84 | - no-store 85 | Cf-Cache-Status: 86 | - DYNAMIC 87 | Content-Length: 88 | - "38" 89 | Content-Type: 90 | - application/json; charset=utf-8 91 | Etag: 92 | - W/"26-1+AHGSycMEc+rIWhJuNJZIAom5A" 93 | Server: 94 | - cloudflare 95 | Vary: 96 | - Accept-Encoding 97 | Via: 98 | - 1.1 google 99 | status: 200 OK 100 | code: 200 101 | duration: 0s 102 | ``` -------------------------------------------------------------------------------- /scripts/register-cline.sh: -------------------------------------------------------------------------------- ```bash 1 | #!/bin/bash 2 | 3 | # This script installs the Linear MCP server and registers it for use with the cline VSCode extension: https://github.com/cline/cline 4 | # Note: to use this, you need to have a) cline installed, and b) set LINEAR_API_KEY in your environment 5 | # 6 | # Usage: 7 | # ./register-cline.sh linear-api-key [write-access] 8 | # 9 | # Parameters: 10 | # linear-api-key: Mandatory. The Linear API key to use for the MCP server. 11 | # write-access: Optional. Set to "true" to enable write operations. Default is "false". 12 | # 13 | # Examples: 14 | # ./register-cline.sh # Install with read-only mode (default) 15 | # ./register-cline.sh true # Install with write operations enabled 16 | 17 | # LINEAR_API_KEY (mandatory) 18 | LINEAR_API_KEY=$1 19 | if [ -z "$LINEAR_API_KEY" ]; then 20 | echo "LINEAR_API_KEY is not set, skipping setup." 21 | exit 1 22 | fi 23 | 24 | # Get the write-access parameter (default: false) 25 | WRITE_ACCESS=${2:-false} 26 | 27 | MCP_SERVERS_DIR="$HOME/mcp-servers" 28 | mkdir -p $MCP_SERVERS_DIR 29 | 30 | # Check if the Linear MCP server binary is on the path already 31 | LINEAR_MCP_BINARY="$(which linear-mcp-go)" 32 | if [ -z "$LINEAR_MCP_BINARY" ]; then 33 | echo "Did not find linear-mcp-go on the path, installing from latest GitHub release..." 34 | 35 | # This fetches information about the latest release to determine the download URL 36 | LATEST_RELEASE=$(curl -s https://api.github.com/repos/geropl/linear-mcp-go/releases/latest) 37 | # Extract the download URL for the Linux binary 38 | DOWNLOAD_URL=$(echo $LATEST_RELEASE | jq -r '.assets[] | select(.name | contains("linux")) | .browser_download_url') 39 | 40 | if [ -z "$DOWNLOAD_URL" ]; then 41 | echo "Error: Could not find Linux binary in the latest release" 42 | exit 1 43 | fi 44 | 45 | # Download the Linear MCP server binary 46 | echo "Downloading Linear MCP server from $DOWNLOAD_URL..." 47 | curl -L -o $MCP_SERVERS_DIR/linear-mcp-go $DOWNLOAD_URL 48 | 49 | # Make the binary executable 50 | chmod +x $MCP_SERVERS_DIR/linear-mcp-go 51 | 52 | echo "Linear MCP server installed successfully at $MCP_SERVERS_DIR/linear-mcp-go" 53 | LINEAR_MCP_BINARY="$MCP_SERVERS_DIR/linear-mcp-go" 54 | fi 55 | 56 | # Configure cline to use the MCP server 57 | # This is where Cline looks for MCP server configurations 58 | CLINE_CONFIG_DIR="$HOME/.vscode-server/data/User/globalStorage/saoudrizwan.claude-dev/settings" 59 | mkdir -p "$CLINE_CONFIG_DIR" 60 | 61 | CLINE_MCP_SETTINGS="$CLINE_CONFIG_DIR/cline_mcp_settings.json" 62 | 63 | # Determine args based on write-access parameter 64 | if [ "$WRITE_ACCESS" = "true" ]; then 65 | SERVER_ARGS='["serve", "--write-access=true"]' 66 | else 67 | SERVER_ARGS='["serve"]' 68 | fi 69 | 70 | # Merge the existing settings with the new MCP server configuration 71 | cat <<EOF > "$CLINE_MCP_SETTINGS.new" 72 | { 73 | "mcpServers": { 74 | "linear": { 75 | "command": "$LINEAR_MCP_BINARY", 76 | "args": $SERVER_ARGS, 77 | "env": { 78 | "LINEAR_API_KEY": "$LINEAR_API_KEY" 79 | }, 80 | "disabled": false, 81 | "autoApprove": [] 82 | } 83 | } 84 | } 85 | EOF 86 | 87 | if [ -f "$CLINE_MCP_SETTINGS" ]; then 88 | echo "Found existing Cline MCP settings at $CLINE_MCP_SETTINGS" 89 | echo "Merging with new MCP server configuration..." 90 | jq -s '.[0] * .[1]' "$CLINE_MCP_SETTINGS" "$CLINE_MCP_SETTINGS.new" > "$CLINE_MCP_SETTINGS.tmp" 91 | mv "$CLINE_MCP_SETTINGS.tmp" "$CLINE_MCP_SETTINGS" 92 | else 93 | mv "$CLINE_MCP_SETTINGS.new" "$CLINE_MCP_SETTINGS" 94 | fi 95 | rm -f "$CLINE_MCP_SETTINGS.new" 96 | 97 | echo "Cline MCP settings updated at $CLINE_MCP_SETTINGS" 98 | ``` -------------------------------------------------------------------------------- /testdata/fixtures/get_initiative_handler_By name.yaml: -------------------------------------------------------------------------------- ```yaml 1 | --- 2 | version: 2 3 | interactions: 4 | - id: 0 5 | request: 6 | proto: HTTP/1.1 7 | proto_major: 1 8 | proto_minor: 1 9 | content_length: 196 10 | transfer_encoding: [] 11 | trailer: {} 12 | host: api.linear.app 13 | remote_addr: "" 14 | request_uri: "" 15 | body: '{"query":"\n\t\tquery GetInitiative($id: String!) {\n\t\t\tinitiative(id: $id) {\n\t\t\t\tid\n\t\t\t\tname\n\t\t\t\tdescription\n\t\t\t\turl\n\t\t\t}\n\t\t}\n\t","variables":{"id":"Push for MCP"}}' 16 | form: {} 17 | headers: 18 | Content-Type: 19 | - application/json 20 | url: https://api.linear.app/graphql 21 | method: POST 22 | response: 23 | proto: HTTP/2.0 24 | proto_major: 2 25 | proto_minor: 0 26 | transfer_encoding: [] 27 | trailer: {} 28 | content_length: -1 29 | uncompressed: true 30 | body: | 31 | {"errors":[{"message":"Entity not found: Initiative","path":["initiative"],"locations":[{"line":3,"column":4}],"extensions":{"type":"invalid input","code":"INPUT_ERROR","statusCode":400,"userError":true,"userPresentableMessage":"Could not find referenced Initiative."}}],"data":null} 32 | headers: 33 | Alt-Svc: 34 | - h3=":443"; ma=86400 35 | Cache-Control: 36 | - no-store 37 | Cf-Cache-Status: 38 | - DYNAMIC 39 | Content-Type: 40 | - application/json; charset=utf-8 41 | Etag: 42 | - W/"11c-c44P18yu0Ek2xjOBk4Ycpqr5fPg" 43 | Server: 44 | - cloudflare 45 | Vary: 46 | - Accept-Encoding 47 | Via: 48 | - 1.1 google 49 | status: 200 OK 50 | code: 200 51 | duration: 0s 52 | - id: 1 53 | request: 54 | proto: HTTP/1.1 55 | proto_major: 1 56 | proto_minor: 1 57 | content_length: 290 58 | transfer_encoding: [] 59 | trailer: {} 60 | host: api.linear.app 61 | remote_addr: "" 62 | request_uri: "" 63 | body: '{"query":"\n\t\tquery GetInitiativeByName($filter: InitiativeFilter) {\n\t\t\tinitiatives(filter: $filter, first: 1) {\n\t\t\t\tnodes {\n\t\t\t\t\tid\n\t\t\t\t\tname\n\t\t\t\t\tdescription\n\t\t\t\t\turl\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t","variables":{"filter":{"name":{"eq":"Push for MCP"}}}}' 64 | form: {} 65 | headers: 66 | Content-Type: 67 | - application/json 68 | url: https://api.linear.app/graphql 69 | method: POST 70 | response: 71 | proto: HTTP/2.0 72 | proto_major: 2 73 | proto_minor: 0 74 | transfer_encoding: [] 75 | trailer: {} 76 | content_length: -1 77 | uncompressed: true 78 | body: | 79 | {"data":{"initiatives":{"nodes":[{"id":"15e7c1bd-c0c5-4801-ac9a-8e98bf88ea7a","name":"Push for MCP","description":null,"url":"https://linear.app/linear-mcp-go-test/initiative/push-for-mcp-f45c0f78f676"}]}}} 80 | headers: 81 | Alt-Svc: 82 | - h3=":443"; ma=86400 83 | Cache-Control: 84 | - no-store 85 | Cf-Cache-Status: 86 | - DYNAMIC 87 | Content-Type: 88 | - application/json; charset=utf-8 89 | Etag: 90 | - W/"cf-Y4qKSrkoz5y3ppN8YkaP7va+sEU" 91 | Server: 92 | - cloudflare 93 | Vary: 94 | - Accept-Encoding 95 | Via: 96 | - 1.1 google 97 | status: 200 OK 98 | code: 200 99 | duration: 0s 100 | ``` -------------------------------------------------------------------------------- /pkg/tools/update_issue.go: -------------------------------------------------------------------------------- ```go 1 | package tools 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/geropl/linear-mcp-go/pkg/linear" 8 | "github.com/mark3labs/mcp-go/mcp" 9 | ) 10 | 11 | // UpdateIssueTool is the tool definition for updating issues 12 | var UpdateIssueTool = mcp.NewTool("linear_update_issue", 13 | mcp.WithDescription("Updates an existing Linear issue."), 14 | mcp.WithString("issue", mcp.Required(), mcp.Description("Issue ID or identifier (e.g., 'TEAM-123')")), 15 | mcp.WithString("title", mcp.Description("New title")), 16 | mcp.WithString("description", mcp.Description("New description")), 17 | mcp.WithString("priority", getPriorityOptions()...), 18 | mcp.WithString("status", mcp.Description("New status")), 19 | mcp.WithString("team", mcp.Description("New team (UUID, name, or key)")), 20 | mcp.WithString("projectId", mcp.Description("New project ID")), 21 | mcp.WithString("milestoneId", mcp.Description("New milestone ID")), 22 | ) 23 | 24 | // UpdateIssueHandler handles the linear_update_issue tool 25 | func UpdateIssueHandler(linearClient *linear.LinearClient) func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { 26 | return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { 27 | // Extract arguments 28 | issueIdentifier, err := request.RequireString("issue") 29 | if err != nil { 30 | return &mcp.CallToolResult{IsError: true, Content: []mcp.Content{mcp.TextContent{Type: "text", Text: err.Error()}}}, nil 31 | } 32 | 33 | // Resolve issue identifier to a UUID 34 | id, err := resolveIssueIdentifier(linearClient, issueIdentifier) 35 | if err != nil { 36 | return &mcp.CallToolResult{IsError: true, Content: []mcp.Content{mcp.TextContent{Type: "text", Text: fmt.Sprintf("Failed to resolve issue: %v", err)}}}, nil 37 | } 38 | 39 | // Extract optional arguments 40 | title := request.GetString("title", "") 41 | description := request.GetString("description", "") 42 | 43 | var priority *int 44 | if priorityStr, err := request.RequireString("priority"); err == nil && priorityStr != "" { 45 | p, err := parsePriority(priorityStr) 46 | if err != nil { 47 | return &mcp.CallToolResult{IsError: true, Content: []mcp.Content{mcp.TextContent{Type: "text", Text: fmt.Sprintf("Invalid priority: %v", err)}}}, nil 48 | } 49 | priority = &p 50 | } 51 | 52 | // Resolve team identifier to a team ID 53 | var teamID string 54 | team := request.GetString("team", "") 55 | if team != "" { 56 | // Resolve team identifier to a team ID 57 | teamID, err = resolveTeamIdentifier(linearClient, team) 58 | if err != nil { 59 | return &mcp.CallToolResult{IsError: true, Content: []mcp.Content{mcp.TextContent{Type: "text", Text: fmt.Sprintf("Failed to resolve team: %v", err)}}}, nil 60 | } 61 | } 62 | 63 | status := request.GetString("status", "") 64 | projectID := request.GetString("projectId", "") 65 | milestoneID := request.GetString("milestoneId", "") 66 | 67 | // Update the issue 68 | input := linear.UpdateIssueInput{ 69 | ID: id, 70 | Title: title, 71 | Description: description, 72 | Priority: priority, 73 | Status: status, 74 | TeamID: teamID, 75 | ProjectID: projectID, 76 | MilestoneID: milestoneID, 77 | } 78 | 79 | issue, err := linearClient.UpdateIssue(input) 80 | if err != nil { 81 | return &mcp.CallToolResult{IsError: true, Content: []mcp.Content{mcp.TextContent{Type: "text", Text: fmt.Sprintf("Failed to update issue: %v", err)}}}, nil 82 | } 83 | 84 | // Return the result 85 | resultText := fmt.Sprintf("Updated %s", formatIssueIdentifier(issue)) 86 | resultText += fmt.Sprintf("\nURL: %s", issue.URL) 87 | return &mcp.CallToolResult{Content: []mcp.Content{mcp.TextContent{Type: "text", Text: resultText}}}, nil 88 | } 89 | } 90 | ```