This is page 1 of 5. Use http://codebase.md/geropl/linear-mcp-go?page={x} to view the full context. # Directory Structure ``` ├── .clinerules │ └── memory-bank.md ├── .devcontainer │ ├── devcontainer.json │ └── Dockerfile ├── .github │ └── workflows │ └── release.yml ├── .gitignore ├── .gitpod.yml ├── cmd │ ├── root.go │ ├── serve.go │ ├── setup_test.go │ ├── setup.go │ └── version.go ├── docs │ ├── design │ │ ├── 001-mcp-go-upgrade.md │ │ └── 002-project-milestone-initiative.md │ └── prd │ ├── 000-tool-standardization-overview.md │ ├── 001-api-refresher.md │ ├── 002-tool-standardization.md │ ├── 003-tool-standardization-implementation.md │ ├── 004-tool-standardization-tracking.md │ ├── 005-sample-implementation.md │ ├── 006-issue-comments-pagination.md │ └── README.md ├── go.mod ├── go.sum ├── main.go ├── memory-bank │ ├── activeContext.md │ ├── developmentWorkflows.md │ ├── productContext.md │ ├── progress.md │ ├── projectbrief.md │ ├── systemPatterns.md │ └── techContext.md ├── pkg │ ├── linear │ │ ├── client.go │ │ ├── models.go │ │ ├── rate_limiter.go │ │ └── test_helpers.go │ ├── server │ │ ├── resources_test.go │ │ ├── resources.go │ │ ├── server.go │ │ ├── test_helpers.go │ │ └── tools_test.go │ └── tools │ ├── add_comment.go │ ├── common.go │ ├── create_issue.go │ ├── get_issue_comments.go │ ├── get_issue.go │ ├── get_teams.go │ ├── get_user_issues.go │ ├── initiative_tools.go │ ├── milestone_tools.go │ ├── priority_test.go │ ├── priority.go │ ├── project_tools.go │ ├── rendering.go │ ├── reply_to_comment.go │ ├── search_issues.go │ ├── update_issue_comment.go │ └── update_issue.go ├── README.md ├── scripts │ └── register-cline.sh └── testdata ├── fixtures │ ├── add_comment_handler_Missing body.yaml │ ├── add_comment_handler_Missing issue.yaml │ ├── add_comment_handler_Missing issueId.yaml │ ├── add_comment_handler_Reply with shorthand.yaml │ ├── add_comment_handler_Reply with URL.yaml │ ├── add_comment_handler_Reply_to_comment.yaml │ ├── add_comment_handler_Valid comment.yaml │ ├── create_initiative_handler_Missing name.yaml │ ├── create_initiative_handler_Valid initiative.yaml │ ├── create_initiative_handler_With description.yaml │ ├── create_issue_handler_Create issue with invalid project.yaml │ ├── create_issue_handler_Create issue with labels.yaml │ ├── create_issue_handler_Create issue with project ID.yaml │ ├── create_issue_handler_Create issue with project name.yaml │ ├── create_issue_handler_Create issue with project slug.yaml │ ├── create_issue_handler_Create sub issue from identifier.yaml │ ├── create_issue_handler_Create sub issue with labels.yaml │ ├── create_issue_handler_Create sub issue.yaml │ ├── create_issue_handler_Invalid team.yaml │ ├── create_issue_handler_Missing team.yaml │ ├── create_issue_handler_Missing teamId.yaml │ ├── create_issue_handler_Missing title.yaml │ ├── create_issue_handler_Valid issue with team key.yaml │ ├── create_issue_handler_Valid issue with team name.yaml │ ├── create_issue_handler_Valid issue with team UUID.yaml │ ├── create_issue_handler_Valid issue with team.yaml │ ├── create_issue_handler_Valid issue with teamId.yaml │ ├── create_issue_handler_Valid issue.yaml │ ├── create_milestone_handler_Invalid project ID.yaml │ ├── create_milestone_handler_Missing name.yaml │ ├── create_milestone_handler_Valid milestone.yaml │ ├── create_milestone_handler_With all optional fields.yaml │ ├── create_project_handler_Invalid team ID.yaml │ ├── create_project_handler_Missing name.yaml │ ├── create_project_handler_Valid project.yaml │ ├── create_project_handler_With all optional fields.yaml │ ├── get_initiative_handler_By name.yaml │ ├── get_initiative_handler_Non-existent name.yaml │ ├── get_initiative_handler_Valid initiative.yaml │ ├── get_issue_comments_handler_Invalid issue.yaml │ ├── get_issue_comments_handler_Missing issue.yaml │ ├── get_issue_comments_handler_Thread_with_pagination.yaml │ ├── get_issue_comments_handler_Valid issue.yaml │ ├── get_issue_comments_handler_With limit.yaml │ ├── get_issue_comments_handler_With_thread_parameter.yaml │ ├── get_issue_handler_Get comment issue.yaml │ ├── get_issue_handler_Missing issue.yaml │ ├── get_issue_handler_Missing issueId.yaml │ ├── get_issue_handler_Valid issue.yaml │ ├── get_milestone_handler_By name.yaml │ ├── get_milestone_handler_Non-existent milestone.yaml │ ├── get_milestone_handler_Valid milestone.yaml │ ├── get_project_handler_By ID.yaml │ ├── get_project_handler_By name.yaml │ ├── get_project_handler_By slug.yaml │ ├── get_project_handler_Invalid project.yaml │ ├── get_project_handler_Missing project param.yaml │ ├── get_project_handler_Non-existent slug.yaml │ ├── get_teams_handler_Get Teams.yaml │ ├── get_user_issues_handler_Current user issues.yaml │ ├── get_user_issues_handler_Specific user issues.yaml │ ├── reply_to_comment_handler_Missing body.yaml │ ├── reply_to_comment_handler_Missing thread.yaml │ ├── reply_to_comment_handler_Reply with URL.yaml │ ├── reply_to_comment_handler_Valid reply.yaml │ ├── resource_TeamResourceHandler_Fetch By ID.yaml │ ├── resource_TeamResourceHandler_Fetch By Key.yaml │ ├── resource_TeamResourceHandler_Fetch By Name.yaml │ ├── resource_TeamResourceHandler_Invalid ID.yaml │ ├── resource_TeamResourceHandler_Missing ID.yaml │ ├── resource_TeamsResourceHandler_List All.yaml │ ├── search_issues_handler_Search by query.yaml │ ├── search_issues_handler_Search by team.yaml │ ├── search_projects_handler_Empty query.yaml │ ├── search_projects_handler_Multiple results.yaml │ ├── search_projects_handler_No results.yaml │ ├── search_projects_handler_Search by query.yaml │ ├── update_comment_handler_Invalid comment identifier.yaml │ ├── update_comment_handler_Missing body.yaml │ ├── update_comment_handler_Missing comment.yaml │ ├── update_comment_handler_Valid comment update with hash only.yaml │ ├── update_comment_handler_Valid comment update with shorthand.yaml │ ├── update_comment_handler_Valid comment update.yaml │ ├── update_initiative_handler_Non-existent initiative.yaml │ ├── update_initiative_handler_Valid update.yaml │ ├── update_issue_handler_Missing id.yaml │ ├── update_issue_handler_Valid update.yaml │ ├── update_milestone_handler_Non-existent milestone.yaml │ ├── update_milestone_handler_Valid update.yaml │ ├── update_project_handler_Non-existent project.yaml │ ├── update_project_handler_Update name and description.yaml │ ├── update_project_handler_Update only description.yaml │ └── update_project_handler_Valid update.yaml └── golden ├── add_comment_handler_Missing body.golden ├── add_comment_handler_Missing issue.golden ├── add_comment_handler_Missing issueId.golden ├── add_comment_handler_Reply with shorthand.golden ├── add_comment_handler_Reply with URL.golden ├── add_comment_handler_Reply_to_comment.golden ├── add_comment_handler_Valid comment.golden ├── create_initiative_handler_Missing name.golden ├── create_initiative_handler_Valid initiative.golden ├── create_initiative_handler_With description.golden ├── create_issue_handler_Create issue with invalid project.golden ├── create_issue_handler_Create issue with labels.golden ├── create_issue_handler_Create issue with project ID.golden ├── create_issue_handler_Create issue with project name.golden ├── create_issue_handler_Create issue with project slug.golden ├── create_issue_handler_Create sub issue from identifier.golden ├── create_issue_handler_Create sub issue with labels.golden ├── create_issue_handler_Create sub issue.golden ├── create_issue_handler_Invalid team.golden ├── create_issue_handler_Missing team.golden ├── create_issue_handler_Missing teamId.golden ├── create_issue_handler_Missing title.golden ├── create_issue_handler_Valid issue with team key.golden ├── create_issue_handler_Valid issue with team name.golden ├── create_issue_handler_Valid issue with team UUID.golden ├── create_issue_handler_Valid issue with team.golden ├── create_issue_handler_Valid issue with teamId.golden ├── create_issue_handler_Valid issue.golden ├── create_milestone_handler_Invalid project ID.golden ├── create_milestone_handler_Missing name.golden ├── create_milestone_handler_Valid milestone.golden ├── create_milestone_handler_With all optional fields.golden ├── create_project_handler_Invalid team ID.golden ├── create_project_handler_Missing name.golden ├── create_project_handler_Valid project.golden ├── create_project_handler_With all optional fields.golden ├── get_initiative_handler_By name.golden ├── get_initiative_handler_Non-existent name.golden ├── get_initiative_handler_Valid initiative.golden ├── get_issue_comments_handler_Invalid issue.golden ├── get_issue_comments_handler_Missing issue.golden ├── get_issue_comments_handler_Thread_with_pagination.golden ├── get_issue_comments_handler_Valid issue.golden ├── get_issue_comments_handler_With limit.golden ├── get_issue_comments_handler_With_thread_parameter.golden ├── get_issue_handler_Get comment issue.golden ├── get_issue_handler_Missing issue.golden ├── get_issue_handler_Missing issueId.golden ├── get_issue_handler_Valid issue.golden ├── get_milestone_handler_By name.golden ├── get_milestone_handler_Non-existent milestone.golden ├── get_milestone_handler_Valid milestone.golden ├── get_project_handler_By ID.golden ├── get_project_handler_By name.golden ├── get_project_handler_By slug.golden ├── get_project_handler_Invalid project.golden ├── get_project_handler_Missing project param.golden ├── get_project_handler_Non-existent slug.golden ├── get_teams_handler_Get Teams.golden ├── get_user_issues_handler_Current user issues.golden ├── get_user_issues_handler_Specific user issues.golden ├── reply_to_comment_handler_Missing body.golden ├── reply_to_comment_handler_Missing thread.golden ├── reply_to_comment_handler_Reply with URL.golden ├── reply_to_comment_handler_Valid reply.golden ├── resource_TeamResourceHandler_Fetch By ID.golden ├── resource_TeamResourceHandler_Fetch By Key.golden ├── resource_TeamResourceHandler_Fetch By Name.golden ├── resource_TeamResourceHandler_Invalid ID.golden ├── resource_TeamResourceHandler_Missing ID.golden ├── resource_TeamsResourceHandler_List All.golden ├── search_issues_handler_Search by query.golden ├── search_issues_handler_Search by team.golden ├── search_projects_handler_Empty query.golden ├── search_projects_handler_Multiple results.golden ├── search_projects_handler_No results.golden ├── search_projects_handler_Search by query.golden ├── update_comment_handler_Invalid comment identifier.golden ├── update_comment_handler_Missing body.golden ├── update_comment_handler_Missing comment.golden ├── update_comment_handler_Valid comment update with hash only.golden ├── update_comment_handler_Valid comment update with shorthand.golden ├── update_comment_handler_Valid comment update.golden ├── update_initiative_handler_Non-existent initiative.golden ├── update_initiative_handler_Valid update.golden ├── update_issue_handler_Missing id.golden ├── update_issue_handler_Valid update.golden ├── update_milestone_handler_Non-existent milestone.golden ├── update_milestone_handler_Valid update.golden ├── update_project_handler_Non-existent project.golden ├── update_project_handler_Update name and description.golden ├── update_project_handler_Update only description.golden └── update_project_handler_Valid update.golden ``` # Files -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- ``` linear-mcp-go .context ``` -------------------------------------------------------------------------------- /.gitpod.yml: -------------------------------------------------------------------------------- ```yaml tasks: - name: Build and test the Go project init: | go build ./... && go test ./... - name: Setup Linear MCP Server and Cline init: | # Install Linear MCP Server and register with Linear Cline # Note: make sure to set LINEAR_API_KEY in the Gitpod environment variables for the MCP server to work ./scripts/register-cline.sh # Additional Gitpod configuration ports: - port: 3000-8000 onOpen: ignore vscode: extensions: - golang.go ``` -------------------------------------------------------------------------------- /docs/prd/README.md: -------------------------------------------------------------------------------- ```markdown # Linear MCP Server PRD Documentation This directory contains Product Requirements Documents (PRDs) for the Linear MCP Server project. ## Available Documents | Document | Description | |----------|-------------| | [000-tool-standardization-overview.md](./000-tool-standardization-overview.md) | Executive summary and overview of the tool standardization effort | | [001-api-refresher.md](./001-api-refresher.md) | Documentation on the Linear API integration | | [002-tool-standardization.md](./002-tool-standardization.md) | Detailed requirements for tool standardization | | [003-tool-standardization-implementation.md](./003-tool-standardization-implementation.md) | Implementation guide for tool standardization | | [004-tool-standardization-tracking.md](./004-tool-standardization-tracking.md) | Tracking sheet for implementation progress | | [005-sample-implementation.md](./005-sample-implementation.md) | Sample code and implementation examples | ## Tool Standardization Series 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: 1. **Rule 1: Concise Tool Descriptions** - Tool descriptions should be concise and focus only on the tool's purpose and functionality 2. **Rule 2: Flexible Object Identifier Resolution** - Input arguments that reference Linear objects should handle multiple values that identify the object 3. **Rule 3: Consistent Entity Rendering** - Tools fetching the same entities should emit results using the same format ## How to Use This Documentation 1. Start with [000-tool-standardization-overview.md](./000-tool-standardization-overview.md) for a high-level overview 2. Read [002-tool-standardization.md](./002-tool-standardization.md) for detailed requirements 3. Refer to [003-tool-standardization-implementation.md](./003-tool-standardization-implementation.md) for implementation details 4. Use [004-tool-standardization-tracking.md](./004-tool-standardization-tracking.md) to track progress 5. See [005-sample-implementation.md](./005-sample-implementation.md) for code examples ## Contributing When adding new PRDs to this directory, follow these guidelines: 1. Use a three-digit prefix (e.g., 006-) to ensure proper ordering 2. Include a clear title that describes the document's purpose 3. Link to related documents when appropriate 4. Update this README.md file to include the new document ``` -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- ```markdown # Linear MCP Server 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. ## Features - Create, update, and search Linear issues - Get issues assigned to a user - Add comments to issues and reply to existing comments - **URL-aware comment operations** - paste Linear comment URLs directly, no manual ID extraction needed - Retrieve team information - Rate-limited API requests to respect Linear's API limits ## Prerequisites - Go 1.23 or higher - Linear API key ## Installation ### From Releases Pre-built binaries are available for Linux, macOS, and Windows on the [GitHub Releases page](https://github.com/geropl/linear-mcp-go/releases). 1. Download the appropriate binary for your platform 2. Make it executable (Linux/macOS): ```bash chmod +x linear-mcp-go-* ``` 3. Run the binary as described in the Usage section ### Automated ``` # Download linux binary for the latest release RELEASE=$(curl -s https://api.github.com/repos/geropl/linear-mcp-go/releases/latest) DOWNLOAD_URL=$(echo $RELEASE | jq -r '.assets[] | select(.name | contains("linux")) | .browser_download_url') curl -L -o ./linear-mcp-go $DOWNLOAD_URL chmod +x ./linear-mcp-go # Setup the mcp server (.gitpod.yml, dotfiles repo, etc.) ./linear-mcp-go setup --tool=cline ``` ## Usage ### Checking Version To check the version of the Linear MCP server: ```bash ./linear-mcp-go version ``` This will display the version, git commit, and build date information. ### Running the Server 1. Set your Linear API key as an environment variable: ```bash export LINEAR_API_KEY=your_linear_api_key ``` 2. Run the server: ```bash # Run in read-only mode (default) ./linear-mcp-go serve # Run with write access enabled ./linear-mcp-go serve --write-access ``` The server will start and listen for MCP requests on stdin/stdout. ### Setting Up for AI Assistants The `setup` command automates the installation and configuration process for various AI assistants: ```bash # Set your Linear API key as an environment variable # Only exception: Ona does not require this for setup! export LINEAR_API_KEY=your_linear_api_key # Set up for Cline (default) ./linear-mcp-go setup # Set up with write access enabled ./linear-mcp-go setup --write-access # Set up with auto-approval for read-only tools ./linear-mcp-go setup --auto-approve=allow-read-only # Set up with specific tools auto-approved ./linear-mcp-go setup --auto-approve=linear_get_issue,linear_search_issues # Set up with write access and auto-approval for read-only tools ./linear-mcp-go setup --write-access --auto-approve=allow-read-only # Set up for a different tool (only "cline" supported for now) ./linear-mcp-go setup --tool=cline ``` This command: 1. Checks if the Linear MCP binary is already installed 2. Copies the current binary to the installation directory if needed 3. Configures the AI assistant to use the Linear MCP server 4. Sets up auto-approval for specified tools if requested The `--auto-approve` flag can be used to specify which tools should be auto-approved in the Cline configuration: - `--auto-approve=allow-read-only`: Auto-approves all read-only tools (`linear_search_issues`, `linear_get_user_issues`, `linear_get_issue`, `linear_get_teams`) - `--auto-approve=tool1,tool2,...`: Auto-approves the specified comma-separated list of tools Currently supported AI assistants: - Cline (VSCode extension) By default, the server runs in read-only mode, which means the following tools are disabled: - `linear_create_issue` - `linear_update_issue` - `linear_add_comment` - `linear_reply_to_comment` - `linear_update_issue_comment` To enable these tools, use the `--write-access=true` flag. ## Available Tools ### linear_create_issue Creates a new Linear issue with specified details. **Supports creating parent-child relationships** (sub-issues) and assigning labels. **Parameters:** - `title` (required): Issue title - `team` (required): Team identifier (key, UUID or name) - `description`: Issue description - `priority`: Priority. Accepts: 0/'no priority', 1/'urgent', 2/'high', 3/'medium', 4/'low' - `status`: Issue status - `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. - `labels`: Optional comma-separated list of label IDs or names to assign - `project`: Optional project identifier (ID, name, or slug) to assign the issue to **Example: Creating a sub-issue** ```json { "title": "Implement login form validation", "team": "ENG", "makeSubissueOf": "ENG-42", "description": "Add client-side validation for the login form" } ``` ### linear_update_issue Updates an existing Linear issue's properties. **Parameters:** - `id` (required): Issue ID - `title`: New title - `description`: New description - `priority`: Priority. Accepts: 0/'no priority', 1/'urgent', 2/'high', 3/'medium', 4/'low' - `status`: New status ### linear_search_issues Searches Linear issues using flexible criteria. **Parameters:** - `query`: Optional text to search in title and description - `teamId`: Filter by team ID - `status`: Filter by status name (e.g., 'In Progress', 'Done') - `assigneeId`: Filter by assignee's user ID - `labels`: Filter by label names (comma-separated) - `priority`: Priority. Accepts: 0/'no priority', 1/'urgent', 2/'high', 3/'medium', 4/'low' - `estimate`: Filter by estimate points - `includeArchived`: Include archived issues in results (default: false) - `limit`: Max results to return (default: 10) ### linear_get_user_issues Retrieves issues assigned to a specific user or the authenticated user. **Parameters:** - `userId`: Optional user ID. If not provided, returns authenticated user's issues - `includeArchived`: Include archived issues in results - `limit`: Maximum number of issues to return (default: 50) ### linear_get_issue Retrieves a single Linear issue by its ID. **Parameters:** - `issueId` (required): ID of the issue to retrieve ### linear_add_comment Adds a comment to an existing Linear issue. Supports replying to existing comments by passing a comment identifier in the `thread` parameter. **Parameters:** - `issue` (required): ID or identifier (e.g., 'TEAM-123') of the issue to comment on - `body` (required): Comment text in markdown format - `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. - `createAsUser`: Optional custom username to show for the comment **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. ### linear_reply_to_comment 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. **Parameters:** - `thread` (required): Comment to reply to. Accepts: full Linear comment URL, UUID, shorthand (comment-abc123), or hash (abc123) - `body` (required): Reply text in markdown format - `createAsUser`: Optional custom username to show for the reply **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. ### linear_get_issue_comments Retrieves comments for a Linear issue with support for pagination and thread navigation. **Parameters:** - `issue` (required): ID or identifier (e.g., 'TEAM-123') of the issue to retrieve comments for - `thread`: Optional UUID of a parent comment to retrieve its replies. If not provided, returns top-level comments - `limit`: Maximum number of comments to return (default: 10) - `after`: Cursor for pagination, to get comments after this point **Use Cases:** - View all comments on an issue - Navigate comment threads by passing a comment UUID in the `thread` parameter - Get comment UUIDs for replying (though with URL support in `linear_add_comment`, this is less necessary) ### linear_update_issue_comment Updates an existing comment on a Linear issue. **Parameters:** - `comment` (required): Comment identifier to update. Accepts: full Linear comment URL, UUID, shorthand (comment-abc123), or hash (abc123) - `body` (required): New comment text in markdown format **URL Support:** Like other comment tools, this accepts full Linear comment URLs and automatically resolves them to UUIDs. ### linear_get_teams Retrieves Linear teams with an optional name filter. **Parameters:** - `name`: Optional team name filter. Returns teams whose names contain this string. ## Test Tests are implemented using [`go-vcr`](https://github.com/dnaeon/go-vcr), and executed against https://linear.app/linear-mcp-go-test. ### Execute tests Using the existing recordings (cassettes): ``` go test -v ./... ``` #### Re-recording test: Requires `TEST_LINEAR_API_KEY` to be set for the test workspace. ``` go test -v -record=true ./... ``` This will update all tests that don't alter remote state. ``` go test -v -recordWrites=true ./... ``` 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. ``` go test -v -golden=true ./... ``` Updates all .golden fields. ## Release Process The project uses GitHub Actions for automated testing and releases. The version is managed through the `ServerVersion` constant in `pkg/server/server.go`. ### Automated Testing and Building 1. All pushes to the main branch and pull requests are automatically tested 2. When a tag matching the pattern `v*` (e.g., `v1.0.0`) is pushed, a new release is automatically created 3. Binaries for Linux, macOS, and Windows are built and attached to the release with build-time information (git commit and build date) ### Creating a New Release **Important**: Version tags should only be created against the `main` branch after all changes have been merged. 1. **Update the version**: Modify the `ServerVersion` constant in `pkg/server/server.go` ```go // ServerVersion is the version of the MCP server ServerVersion = "1.13.0" ``` 2. **Create a PR**: Submit the version update as a pull request to ensure it goes through review and testing 3. **Merge to main**: Once the PR is approved and merged to the main branch 4. **Create and push the release tag**: ```bash # Ensure you're on the latest main branch git checkout main git pull origin main # Create and push the tag (must match the version in server.go) git tag v1.13.0 git push origin v1.13.0 ``` 5. **Automated release**: The GitHub Actions workflow will automatically: - Build binaries for all platforms with proper version information - Create a GitHub release with the tag - Attach the compiled binaries to the release ### Version Information The `version` command displays: - **Version**: Read from `ServerVersion` constant in `pkg/server/server.go` - **Git commit**: Injected at build time from the current commit hash - **Build date**: Injected at build time with the current timestamp For development builds, git commit and build date will show "unknown". ## License MIT ``` -------------------------------------------------------------------------------- /testdata/fixtures/add_comment_handler_Missing issue.yaml: -------------------------------------------------------------------------------- ```yaml --- version: 2 interactions: [] ``` -------------------------------------------------------------------------------- /testdata/fixtures/add_comment_handler_Missing issueId.yaml: -------------------------------------------------------------------------------- ```yaml --- version: 2 interactions: [] ``` -------------------------------------------------------------------------------- /testdata/fixtures/create_initiative_handler_Missing name.yaml: -------------------------------------------------------------------------------- ```yaml --- version: 2 interactions: [] ``` -------------------------------------------------------------------------------- /testdata/fixtures/create_issue_handler_Missing team.yaml: -------------------------------------------------------------------------------- ```yaml --- version: 2 interactions: [] ``` -------------------------------------------------------------------------------- /testdata/fixtures/create_issue_handler_Missing teamId.yaml: -------------------------------------------------------------------------------- ```yaml --- version: 2 interactions: [] ``` -------------------------------------------------------------------------------- /testdata/fixtures/create_issue_handler_Missing title.yaml: -------------------------------------------------------------------------------- ```yaml --- version: 2 interactions: [] ``` -------------------------------------------------------------------------------- /testdata/fixtures/create_milestone_handler_Missing name.yaml: -------------------------------------------------------------------------------- ```yaml --- version: 2 interactions: [] ``` -------------------------------------------------------------------------------- /testdata/fixtures/create_project_handler_Missing name.yaml: -------------------------------------------------------------------------------- ```yaml --- version: 2 interactions: [] ``` -------------------------------------------------------------------------------- /testdata/fixtures/get_issue_comments_handler_Missing issue.yaml: -------------------------------------------------------------------------------- ```yaml --- version: 2 interactions: [] ``` -------------------------------------------------------------------------------- /testdata/fixtures/get_issue_handler_Missing issue.yaml: -------------------------------------------------------------------------------- ```yaml --- version: 2 interactions: [] ``` -------------------------------------------------------------------------------- /testdata/fixtures/get_project_handler_Missing project param.yaml: -------------------------------------------------------------------------------- ```yaml --- version: 2 interactions: [] ``` -------------------------------------------------------------------------------- /testdata/fixtures/reply_to_comment_handler_Missing body.yaml: -------------------------------------------------------------------------------- ```yaml --- version: 2 interactions: [] ``` -------------------------------------------------------------------------------- /testdata/fixtures/reply_to_comment_handler_Missing thread.yaml: -------------------------------------------------------------------------------- ```yaml --- version: 2 interactions: [] ``` -------------------------------------------------------------------------------- /testdata/fixtures/resource_TeamResourceHandler_Missing ID.yaml: -------------------------------------------------------------------------------- ```yaml --- version: 2 interactions: [] ``` -------------------------------------------------------------------------------- /testdata/fixtures/search_projects_handler_Empty query.yaml: -------------------------------------------------------------------------------- ```yaml --- version: 2 interactions: [] ``` -------------------------------------------------------------------------------- /testdata/fixtures/update_comment_handler_Missing body.yaml: -------------------------------------------------------------------------------- ```yaml --- version: 2 interactions: [] ``` -------------------------------------------------------------------------------- /testdata/fixtures/update_comment_handler_Missing comment.yaml: -------------------------------------------------------------------------------- ```yaml --- version: 2 interactions: [] ``` -------------------------------------------------------------------------------- /testdata/fixtures/update_issue_handler_Missing id.yaml: -------------------------------------------------------------------------------- ```yaml --- version: 2 interactions: [] ``` -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- ```go package main import "github.com/geropl/linear-mcp-go/cmd" func main() { cmd.Execute() } ``` -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- ```json { "name": "Gitpod", "build": { "context": ".", "dockerfile": "Dockerfile" }, "features": { "ghcr.io/devcontainers/features/go:1": {} } } ``` -------------------------------------------------------------------------------- /.devcontainer/Dockerfile: -------------------------------------------------------------------------------- ```dockerfile FROM mcr.microsoft.com/devcontainers/base:ubuntu-24.04 # use this Dockerfile to install additional tools you might need, e.g. # RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ # && apt-get -y install --no-install-recommends <your-package-list-here> ``` -------------------------------------------------------------------------------- /cmd/version.go: -------------------------------------------------------------------------------- ```go package cmd import ( "fmt" "github.com/geropl/linear-mcp-go/pkg/server" "github.com/spf13/cobra" ) // Build information - these will be set at build time var ( GitCommit = "unknown" BuildDate = "unknown" ) // versionCmd represents the version command var versionCmd = &cobra.Command{ Use: "version", Short: "Print the version information", Long: `Print the version information for the Linear MCP server.`, Run: func(cmd *cobra.Command, args []string) { fmt.Printf("Linear MCP Server %s\n", server.ServerVersion) fmt.Printf("Git commit: %s\n", GitCommit) fmt.Printf("Build date: %s\n", BuildDate) }, } func init() { rootCmd.AddCommand(versionCmd) } ``` -------------------------------------------------------------------------------- /cmd/root.go: -------------------------------------------------------------------------------- ```go package cmd import ( "fmt" "os" "github.com/spf13/cobra" ) // rootCmd represents the base command when called without any subcommands var rootCmd = &cobra.Command{ Use: "linear-mcp-go", Short: "Linear MCP Server - A Model Context Protocol server for Linear", Long: `Linear MCP Server is a Model Context Protocol (MCP) server for Linear. It provides tools for interacting with the Linear API through the MCP protocol, enabling AI assistants to manage Linear issues and workflows.`, } // Execute adds all child commands to the root command and sets flags appropriately. // This is called by main.main(). It only needs to happen once to the rootCmd. func Execute() { if err := rootCmd.Execute(); err != nil { fmt.Println(err) os.Exit(1) } } ``` -------------------------------------------------------------------------------- /memory-bank/projectbrief.md: -------------------------------------------------------------------------------- ```markdown # Project Brief: Linear MCP Server ## Overview 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. ## Core Requirements 1. Provide MCP tools for Linear API operations 2. Handle authentication via Linear API key 3. Support issue creation, updating, and searching 4. Enable comment addition to issues 5. Implement rate limiting to respect Linear's API limits 6. Ensure proper error handling and user feedback ## Goals - Create a reliable interface between AI assistants and Linear - Simplify Linear operations through standardized MCP tools - Maintain compatibility with the MCP protocol specification - Provide comprehensive documentation for users ## Project Scope - **In Scope**: Linear API integration, MCP server implementation, basic error handling, rate limiting - **Out of Scope**: UI development, authentication management beyond API key ## Timeline - Initial development: Complete - Release workflow: In progress - Future enhancements: TBD ## Success Criteria - All specified Linear operations work correctly through MCP tools - Server handles errors gracefully - Documentation is clear and comprehensive - Release process is automated ``` -------------------------------------------------------------------------------- /pkg/tools/get_teams.go: -------------------------------------------------------------------------------- ```go package tools import ( "context" "fmt" "github.com/geropl/linear-mcp-go/pkg/linear" "github.com/mark3labs/mcp-go/mcp" ) // GetTeamsTool is the tool definition for getting teams var GetTeamsTool = mcp.NewTool("linear_get_teams", mcp.WithDescription("Retrieves Linear teams."), mcp.WithString("name", mcp.Description("Optional team name filter. Returns teams whose names contain this string.")), ) // GetTeamsHandler handles the linear_get_teams tool func GetTeamsHandler(linearClient *linear.LinearClient) func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { // Extract arguments name := request.GetString("name", "") // Get teams teams, err := linearClient.GetTeams(name) if err != nil { return &mcp.CallToolResult{IsError: true, Content: []mcp.Content{mcp.TextContent{Type: "text", Text: fmt.Sprintf("Failed to get teams: %v", err)}}}, nil } // Format the result resultText := fmt.Sprintf("Found %d teams:\n", len(teams)) for _, team := range teams { // Create a pointer to the team for formatTeamIdentifier teamPtr := &team resultText += fmt.Sprintf("- %s\n", formatTeamIdentifier(teamPtr)) resultText += fmt.Sprintf(" Key: %s\n", team.Key) } return &mcp.CallToolResult{Content: []mcp.Content{mcp.TextContent{Type: "text", Text: resultText}}}, nil } } ``` -------------------------------------------------------------------------------- /cmd/serve.go: -------------------------------------------------------------------------------- ```go package cmd import ( "fmt" "os" "strings" "github.com/geropl/linear-mcp-go/pkg/server" "github.com/spf13/cobra" ) // serveCmd represents the serve command var serveCmd = &cobra.Command{ Use: "serve", Short: "Start the Linear MCP server", Long: `Start the Linear MCP server that listens for MCP requests on stdin/stdout. The server provides tools for interacting with the Linear API through the MCP protocol.`, Run: func(cmd *cobra.Command, args []string) { writeAccess, _ := cmd.Flags().GetBool("write-access") writeAccessChanged := cmd.Flags().Changed("write-access") // Check LINEAR_WRITE_ACCESS environment variable if flag wasn't explicitly set if !writeAccessChanged { if envWriteAccess := os.Getenv("LINEAR_WRITE_ACCESS"); envWriteAccess != "" { envValue := strings.ToLower(strings.TrimSpace(envWriteAccess)) if envValue == "true" { writeAccess = true } else if envValue == "false" { writeAccess = false } // If the env var is set to something other than "true" or "false", ignore it and use default } } // Create the Linear MCP server linearServer, err := server.NewLinearMCPServer(writeAccess) if err != nil { fmt.Printf("Failed to create Linear MCP server: %v\n", err) os.Exit(1) } // Start the server if err := linearServer.Start(); err != nil { fmt.Printf("Server error: %v\n", err) os.Exit(1) } }, } func init() { rootCmd.AddCommand(serveCmd) // Add flags to the serve command serveCmd.Flags().Bool("write-access", false, "Enable tools that modify Linear data (create/update issues, add comments)") } ``` -------------------------------------------------------------------------------- /testdata/fixtures/get_issue_comments_handler_Invalid issue.yaml: -------------------------------------------------------------------------------- ```yaml --- version: 2 interactions: - id: 0 request: proto: HTTP/1.1 proto_major: 1 proto_minor: 1 content_length: 330 transfer_encoding: [] trailer: {} host: api.linear.app remote_addr: "" request_uri: "" 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"}}' form: {} headers: Content-Type: - application/json url: https://api.linear.app/graphql method: POST response: proto: HTTP/2.0 proto_major: 2 proto_minor: 0 transfer_encoding: [] trailer: {} content_length: 33 uncompressed: false body: | {"data":{"issues":{"nodes":[]}}} headers: Alt-Svc: - h3=":443"; ma=86400 Cache-Control: - no-store Cf-Cache-Status: - DYNAMIC Content-Length: - "33" Content-Type: - application/json; charset=utf-8 Etag: - W/"21-PKFa7EZ3q+7ITZ8vZtp2aqgNJxo" Server: - cloudflare Vary: - Accept-Encoding Via: - 1.1 google status: 200 OK code: 200 duration: 0s ``` -------------------------------------------------------------------------------- /testdata/fixtures/get_issue_handler_Missing issueId.yaml: -------------------------------------------------------------------------------- ```yaml --- version: 2 interactions: - id: 0 request: proto: HTTP/1.1 proto_major: 1 proto_minor: 1 content_length: 330 transfer_encoding: [] trailer: {} host: api.linear.app remote_addr: "" request_uri: "" 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"}}' form: {} headers: Content-Type: - application/json url: https://api.linear.app/graphql method: POST response: proto: HTTP/2.0 proto_major: 2 proto_minor: 0 transfer_encoding: [] trailer: {} content_length: 33 uncompressed: false body: | {"data":{"issues":{"nodes":[]}}} headers: Alt-Svc: - h3=":443"; ma=86400 Cache-Control: - no-store Cf-Cache-Status: - DYNAMIC Content-Length: - "33" Content-Type: - application/json; charset=utf-8 Etag: - W/"21-PKFa7EZ3q+7ITZ8vZtp2aqgNJxo" Server: - cloudflare Vary: - Accept-Encoding Via: - 1.1 google status: 200 OK code: 200 duration: 0s ``` -------------------------------------------------------------------------------- /testdata/fixtures/get_initiative_handler_Valid initiative.yaml: -------------------------------------------------------------------------------- ```yaml --- version: 2 interactions: - id: 0 request: proto: HTTP/1.1 proto_major: 1 proto_minor: 1 content_length: 220 transfer_encoding: [] trailer: {} host: api.linear.app remote_addr: "" request_uri: "" 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"}}' form: {} headers: Content-Type: - application/json url: https://api.linear.app/graphql method: POST response: proto: HTTP/2.0 proto_major: 2 proto_minor: 0 transfer_encoding: [] trailer: {} content_length: -1 uncompressed: true body: | {"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"}}} headers: Alt-Svc: - h3=":443"; ma=86400 Cache-Control: - no-store Cf-Cache-Status: - DYNAMIC Content-Type: - application/json; charset=utf-8 Etag: - W/"d8-hd6//06VmE0Pm1n9UOQfuP2hyLQ" Server: - cloudflare Vary: - Accept-Encoding Via: - 1.1 google status: 200 OK code: 200 duration: 0s ``` -------------------------------------------------------------------------------- /testdata/fixtures/add_comment_handler_Missing body.yaml: -------------------------------------------------------------------------------- ```yaml --- version: 2 interactions: - id: 0 request: proto: HTTP/1.1 proto_major: 1 proto_minor: 1 content_length: 322 transfer_encoding: [] trailer: {} host: api.linear.app remote_addr: "" request_uri: "" 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"}}' form: {} headers: Content-Type: - application/json url: https://api.linear.app/graphql method: POST response: proto: HTTP/2.0 proto_major: 2 proto_minor: 0 transfer_encoding: [] trailer: {} content_length: -1 uncompressed: true body: | {"data":{"issues":{"nodes":[{"id":"1c2de93f-4321-4015-bfde-ee893ef7976f","identifier":"TEST-10","title":"Updated Test Issue"}]}}} headers: Alt-Svc: - h3=":443"; ma=86400 Cache-Control: - no-store Cf-Cache-Status: - DYNAMIC Content-Type: - application/json; charset=utf-8 Etag: - W/"82-w0K/VnjlqJtYAurPyBwU/9QgAFo" Server: - cloudflare Vary: - Accept-Encoding Via: - 1.1 google status: 200 OK code: 200 duration: 0s ``` -------------------------------------------------------------------------------- /pkg/tools/priority.go: -------------------------------------------------------------------------------- ```go package tools import ( "fmt" "strconv" "strings" "github.com/mark3labs/mcp-go/mcp" ) const ( PriorityNone = 0 PriorityUrgent = 1 PriorityHigh = 2 PriorityMedium = 3 PriorityLow = 4 ) var priorityNames = map[int]string{ PriorityNone: "No priority", PriorityUrgent: "Urgent", PriorityHigh: "High", PriorityMedium: "Medium", PriorityLow: "Low", } var priorityFromName = map[string]int{ "no priority": PriorityNone, "none": PriorityNone, "urgent": PriorityUrgent, "high": PriorityHigh, "medium": PriorityMedium, "low": PriorityLow, } // priorityToString converts numeric priority to textual representation func priorityToString(priority int) string { if name, ok := priorityNames[priority]; ok { return name } return "Unknown" } // parsePriority accepts both numeric (0-4) and textual representations // Returns the numeric value and an error if invalid func parsePriority(input string) (int, error) { input = strings.TrimSpace(strings.ToLower(input)) // Try parsing as number first if num, err := strconv.Atoi(input); err == nil { if num >= PriorityNone && num <= PriorityLow { return num, nil } return 0, fmt.Errorf("priority number must be between 0 and 4, got %d", num) } // Try parsing as text if priority, ok := priorityFromName[input]; ok { return priority, nil } return 0, fmt.Errorf("invalid priority: %s (valid values: 0-4, no priority, urgent, high, medium, low)", input) } // getPriorityOptions returns the property options for priority parameters func getPriorityOptions() []mcp.PropertyOption { return []mcp.PropertyOption{ mcp.Description("Priority"), mcp.Enum("no priority", "urgent", "high", "medium", "low"), } } ``` -------------------------------------------------------------------------------- /testdata/fixtures/get_milestone_handler_Valid milestone.yaml: -------------------------------------------------------------------------------- ```yaml --- version: 2 interactions: - id: 0 request: proto: HTTP/1.1 proto_major: 1 proto_minor: 1 content_length: 296 transfer_encoding: [] trailer: {} host: api.linear.app remote_addr: "" request_uri: "" 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"}}' form: {} headers: Content-Type: - application/json url: https://api.linear.app/graphql method: POST response: proto: HTTP/2.0 proto_major: 2 proto_minor: 0 transfer_encoding: [] trailer: {} content_length: -1 uncompressed: true body: | {"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"}}}} headers: Alt-Svc: - h3=":443"; ma=86400 Cache-Control: - no-store Cf-Cache-Status: - DYNAMIC Content-Type: - application/json; charset=utf-8 Etag: - W/"102-BS+utz/wRqBQhmWIePACw/zkABE" Server: - cloudflare Vary: - Accept-Encoding Via: - 1.1 google status: 200 OK code: 200 duration: 0s ``` -------------------------------------------------------------------------------- /testdata/fixtures/create_initiative_handler_Valid initiative.yaml: -------------------------------------------------------------------------------- ```yaml --- version: 2 interactions: - id: 0 request: proto: HTTP/1.1 proto_major: 1 proto_minor: 1 content_length: 313 transfer_encoding: [] trailer: {} host: api.linear.app remote_addr: "" request_uri: "" 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"}}}' form: {} headers: Content-Type: - application/json url: https://api.linear.app/graphql method: POST response: proto: HTTP/2.0 proto_major: 2 proto_minor: 0 transfer_encoding: [] trailer: {} content_length: -1 uncompressed: true body: | {"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"}}}} headers: Alt-Svc: - h3=":443"; ma=86400 Cache-Control: - no-store Cf-Cache-Status: - DYNAMIC Content-Type: - application/json; charset=utf-8 Etag: - W/"fc-gsDkifP3AXu0S0n8HWLEunvFm3g" Server: - cloudflare Vary: - Accept-Encoding Via: - 1.1 google status: 200 OK code: 200 duration: 0s ``` -------------------------------------------------------------------------------- /testdata/fixtures/update_comment_handler_Invalid comment identifier.yaml: -------------------------------------------------------------------------------- ```yaml --- version: 2 interactions: - id: 0 request: proto: HTTP/1.1 proto_major: 1 proto_minor: 1 content_length: 265 transfer_encoding: [] trailer: {} host: api.linear.app remote_addr: "" request_uri: "" 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"}}' form: {} headers: Content-Type: - application/json url: https://api.linear.app/graphql method: POST response: proto: HTTP/2.0 proto_major: 2 proto_minor: 0 transfer_encoding: [] trailer: {} content_length: -1 uncompressed: true body: | {"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} headers: Alt-Svc: - h3=":443"; ma=86400 Cache-Control: - no-store Cf-Cache-Status: - DYNAMIC Content-Type: - application/json; charset=utf-8 Etag: - W/"12b-3YQeaABMZ487ZUXapKwikGiK6Xw" Server: - cloudflare Vary: - Accept-Encoding Via: - 1.1 google status: 200 OK code: 200 duration: 0s ``` -------------------------------------------------------------------------------- /testdata/fixtures/search_projects_handler_Search by query.yaml: -------------------------------------------------------------------------------- ```yaml --- version: 2 interactions: - id: 0 request: proto: HTTP/1.1 proto_major: 1 proto_minor: 1 content_length: 311 transfer_encoding: [] trailer: {} host: api.linear.app remote_addr: "" request_uri: "" 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"}}}}' form: {} headers: Content-Type: - application/json url: https://api.linear.app/graphql method: POST response: proto: HTTP/2.0 proto_major: 2 proto_minor: 0 transfer_encoding: [] trailer: {} content_length: -1 uncompressed: true body: | {"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"}]}}} headers: Alt-Svc: - h3=":443"; ma=86400 Cache-Control: - no-store Cf-Cache-Status: - DYNAMIC Content-Type: - application/json; charset=utf-8 Etag: - W/"11b-k8WmIaSsWOTOQAk9sZK3/3eZoaY" Server: - cloudflare Vary: - Accept-Encoding Via: - 1.1 google status: 200 OK code: 200 duration: 0s ``` -------------------------------------------------------------------------------- /testdata/fixtures/create_initiative_handler_With description.yaml: -------------------------------------------------------------------------------- ```yaml --- version: 2 interactions: - id: 0 request: proto: HTTP/1.1 proto_major: 1 proto_minor: 1 content_length: 348 transfer_encoding: [] trailer: {} host: api.linear.app remote_addr: "" request_uri: "" 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"}}}' form: {} headers: Content-Type: - application/json url: https://api.linear.app/graphql method: POST response: proto: HTTP/2.0 proto_major: 2 proto_minor: 0 transfer_encoding: [] trailer: {} content_length: -1 uncompressed: true body: | {"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"}}}} headers: Alt-Svc: - h3=":443"; ma=86400 Cache-Control: - no-store Cf-Cache-Status: - DYNAMIC Content-Type: - application/json; charset=utf-8 Etag: - W/"10e-Z2O6jMzejJ/UoHu3z6jnwHPqPzc" Server: - cloudflare Vary: - Accept-Encoding Via: - 1.1 google status: 200 OK code: 200 duration: 0s ``` -------------------------------------------------------------------------------- /pkg/tools/priority_test.go: -------------------------------------------------------------------------------- ```go package tools import ( "testing" ) func TestParsePriority(t *testing.T) { tests := []struct { name string input string want int wantErr bool }{ // Numeric inputs {"zero", "0", 0, false}, {"one", "1", 1, false}, {"two", "2", 2, false}, {"three", "3", 3, false}, {"four", "4", 4, false}, {"invalid number", "5", 0, true}, {"negative", "-1", 0, true}, // Textual inputs (lowercase) {"no priority", "no priority", 0, false}, {"none", "none", 0, false}, {"urgent", "urgent", 1, false}, {"high", "high", 2, false}, {"medium", "medium", 3, false}, {"low", "low", 4, false}, // Textual inputs (mixed case) {"Urgent", "Urgent", 1, false}, {"HIGH", "HIGH", 2, false}, {"MeDiUm", "MeDiUm", 3, false}, // Whitespace handling {"with spaces", " urgent ", 1, false}, {"with tabs", "\thigh\t", 2, false}, // Invalid inputs {"invalid text", "super-urgent", 0, true}, {"empty", "", 0, true}, {"random", "xyz", 0, true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, err := parsePriority(tt.input) if (err != nil) != tt.wantErr { t.Errorf("parsePriority(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr) return } if got != tt.want { t.Errorf("parsePriority(%q) = %v, want %v", tt.input, got, tt.want) } }) } } func TestPriorityToString(t *testing.T) { tests := []struct { name string priority int want string }{ {"zero", 0, "No priority"}, {"urgent", 1, "Urgent"}, {"high", 2, "High"}, {"medium", 3, "Medium"}, {"low", 4, "Low"}, {"invalid", 5, "Unknown"}, {"negative", -1, "Unknown"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := priorityToString(tt.priority); got != tt.want { t.Errorf("priorityToString(%d) = %v, want %v", tt.priority, got, tt.want) } }) } } ``` -------------------------------------------------------------------------------- /testdata/fixtures/search_projects_handler_No results.yaml: -------------------------------------------------------------------------------- ```yaml --- version: 2 interactions: - id: 0 request: proto: HTTP/1.1 proto_major: 1 proto_minor: 1 content_length: 564 transfer_encoding: [] trailer: {} host: api.linear.app remote_addr: "" request_uri: "" 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"}}}}' form: {} headers: Content-Type: - application/json url: https://api.linear.app/graphql method: POST response: proto: HTTP/2.0 proto_major: 2 proto_minor: 0 transfer_encoding: [] trailer: {} content_length: 35 uncompressed: false body: | {"data":{"projects":{"nodes":[]}}} headers: Alt-Svc: - h3=":443"; ma=86400 Cache-Control: - no-store Cf-Cache-Status: - DYNAMIC Content-Length: - "35" Content-Type: - application/json; charset=utf-8 Etag: - W/"23-qdJEPQ25XhtziwkPAN9bwg0W7eo" Server: - cloudflare Vary: - Accept-Encoding Via: - 1.1 google status: 200 OK code: 200 duration: 0s ``` -------------------------------------------------------------------------------- /testdata/fixtures/create_project_handler_Valid project.yaml: -------------------------------------------------------------------------------- ```yaml --- version: 2 interactions: - id: 0 request: proto: HTTP/1.1 proto_major: 1 proto_minor: 1 content_length: 384 transfer_encoding: [] trailer: {} host: api.linear.app remote_addr: "" request_uri: "" 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"]}}}' form: {} headers: Content-Type: - application/json url: https://api.linear.app/graphql method: POST response: proto: HTTP/2.0 proto_major: 2 proto_minor: 0 transfer_encoding: [] trailer: {} content_length: -1 uncompressed: true body: | {"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"}}}} headers: Alt-Svc: - h3=":443"; ma=86400 Cache-Control: - no-store Cf-Cache-Status: - DYNAMIC Content-Type: - application/json; charset=utf-8 Etag: - W/"115-3XE+XxWz8n/te2YxwxwQ8jghgOQ" Server: - cloudflare Vary: - Accept-Encoding Via: - 1.1 google status: 200 OK code: 200 duration: 0s ``` -------------------------------------------------------------------------------- /testdata/fixtures/create_milestone_handler_Valid milestone.yaml: -------------------------------------------------------------------------------- ```yaml --- version: 2 interactions: - id: 0 request: proto: HTTP/1.1 proto_major: 1 proto_minor: 1 content_length: 458 transfer_encoding: [] trailer: {} host: api.linear.app remote_addr: "" request_uri: "" 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"}}}' form: {} headers: Content-Type: - application/json url: https://api.linear.app/graphql method: POST response: proto: HTTP/2.0 proto_major: 2 proto_minor: 0 transfer_encoding: [] trailer: {} content_length: -1 uncompressed: true body: | {"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"}}}}} headers: Alt-Svc: - h3=":443"; ma=86400 Cache-Control: - no-store Cf-Cache-Status: - DYNAMIC Content-Type: - application/json; charset=utf-8 Etag: - W/"10f-qfcBByYXxFvyqjb46UAlsOog79A" Server: - cloudflare Vary: - Accept-Encoding Via: - 1.1 google status: 200 OK code: 200 duration: 0s ``` -------------------------------------------------------------------------------- /testdata/fixtures/create_project_handler_With all optional fields.yaml: -------------------------------------------------------------------------------- ```yaml --- version: 2 interactions: - id: 0 request: proto: HTTP/1.1 proto_major: 1 proto_minor: 1 content_length: 510 transfer_encoding: [] trailer: {} host: api.linear.app remote_addr: "" request_uri: "" 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"}}}' form: {} headers: Content-Type: - application/json url: https://api.linear.app/graphql method: POST response: proto: HTTP/2.0 proto_major: 2 proto_minor: 0 transfer_encoding: [] trailer: {} content_length: -1 uncompressed: true body: | {"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"}}}} headers: Alt-Svc: - h3=":443"; ma=86400 Cache-Control: - no-store Cf-Cache-Status: - DYNAMIC Content-Type: - application/json; charset=utf-8 Etag: - W/"119-Cuk8W8/7UhfXhWx3IaWopPfEYBU" Server: - cloudflare Vary: - Accept-Encoding Via: - 1.1 google status: 200 OK code: 200 duration: 0s ``` -------------------------------------------------------------------------------- /testdata/fixtures/search_issues_handler_Search by query.yaml: -------------------------------------------------------------------------------- ```yaml --- version: 2 interactions: - id: 0 request: proto: HTTP/1.1 proto_major: 1 proto_minor: 1 content_length: 716 transfer_encoding: [] trailer: {} host: api.linear.app remote_addr: "" request_uri: "" 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}}' form: {} headers: Content-Type: - application/json url: https://api.linear.app/graphql method: POST response: proto: HTTP/2.0 proto_major: 2 proto_minor: 0 transfer_encoding: [] trailer: {} content_length: 33 uncompressed: false body: | {"data":{"issues":{"nodes":[]}}} headers: Alt-Svc: - h3=":443"; ma=86400 Cache-Control: - no-store Cf-Cache-Status: - DYNAMIC Content-Length: - "33" Content-Type: - application/json; charset=utf-8 Etag: - W/"21-PKFa7EZ3q+7ITZ8vZtp2aqgNJxo" Server: - cloudflare Vary: - Accept-Encoding Via: - 1.1 google status: 200 OK code: 200 duration: 0s ``` -------------------------------------------------------------------------------- /testdata/fixtures/create_milestone_handler_With all optional fields.yaml: -------------------------------------------------------------------------------- ```yaml --- version: 2 interactions: - id: 0 request: proto: HTTP/1.1 proto_major: 1 proto_minor: 1 content_length: 517 transfer_encoding: [] trailer: {} host: api.linear.app remote_addr: "" request_uri: "" 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"}}}' form: {} headers: Content-Type: - application/json url: https://api.linear.app/graphql method: POST response: proto: HTTP/2.0 proto_major: 2 proto_minor: 0 transfer_encoding: [] trailer: {} content_length: -1 uncompressed: true body: | {"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"}}}}} headers: Alt-Svc: - h3=":443"; ma=86400 Cache-Control: - no-store Cf-Cache-Status: - DYNAMIC Content-Type: - application/json; charset=utf-8 Etag: - W/"125-gez3UfbdT9/D0VXU4hZmuVEUokw" Server: - cloudflare Vary: - Accept-Encoding Via: - 1.1 google status: 200 OK code: 200 duration: 0s ``` -------------------------------------------------------------------------------- /testdata/fixtures/create_issue_handler_Invalid team.yaml: -------------------------------------------------------------------------------- ```yaml --- version: 2 interactions: - id: 0 request: proto: HTTP/1.1 proto_major: 1 proto_minor: 1 content_length: 310 transfer_encoding: [] trailer: {} host: api.linear.app remote_addr: "" request_uri: "" 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"}' form: {} headers: Content-Type: - application/json url: https://api.linear.app/graphql method: POST response: proto: HTTP/2.0 proto_major: 2 proto_minor: 0 transfer_encoding: [] trailer: {} content_length: -1 uncompressed: true body: | {"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"}]}}]}}} headers: Alt-Svc: - h3=":443"; ma=86400 Cache-Control: - no-store Cf-Cache-Status: - DYNAMIC Content-Type: - application/json; charset=utf-8 Etag: - W/"210-+ISnhlSrm6Gd7LWWbqn3eOeSXhw" Server: - cloudflare Vary: - Accept-Encoding Via: - 1.1 google status: 200 OK code: 200 duration: 0s ``` -------------------------------------------------------------------------------- /testdata/fixtures/resource_TeamResourceHandler_Fetch By ID.yaml: -------------------------------------------------------------------------------- ```yaml --- version: 2 interactions: - id: 0 request: proto: HTTP/1.1 proto_major: 1 proto_minor: 1 content_length: 310 transfer_encoding: [] trailer: {} host: api.linear.app remote_addr: "" request_uri: "" 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"}' form: {} headers: Content-Type: - application/json url: https://api.linear.app/graphql method: POST response: proto: HTTP/2.0 proto_major: 2 proto_minor: 0 transfer_encoding: [] trailer: {} content_length: -1 uncompressed: true body: | {"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"}]}}]}}} headers: Alt-Svc: - h3=":443"; ma=86400 Cache-Control: - no-store Cf-Cache-Status: - DYNAMIC Content-Type: - application/json; charset=utf-8 Etag: - W/"210-+ISnhlSrm6Gd7LWWbqn3eOeSXhw" Server: - cloudflare Vary: - Accept-Encoding Via: - 1.1 google status: 200 OK code: 200 duration: 0s ``` -------------------------------------------------------------------------------- /testdata/fixtures/resource_TeamResourceHandler_Invalid ID.yaml: -------------------------------------------------------------------------------- ```yaml --- version: 2 interactions: - id: 0 request: proto: HTTP/1.1 proto_major: 1 proto_minor: 1 content_length: 310 transfer_encoding: [] trailer: {} host: api.linear.app remote_addr: "" request_uri: "" 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"}' form: {} headers: Content-Type: - application/json url: https://api.linear.app/graphql method: POST response: proto: HTTP/2.0 proto_major: 2 proto_minor: 0 transfer_encoding: [] trailer: {} content_length: -1 uncompressed: true body: | {"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"}]}}]}}} headers: Alt-Svc: - h3=":443"; ma=86400 Cache-Control: - no-store Cf-Cache-Status: - DYNAMIC Content-Type: - application/json; charset=utf-8 Etag: - W/"210-+ISnhlSrm6Gd7LWWbqn3eOeSXhw" Server: - cloudflare Vary: - Accept-Encoding Via: - 1.1 google status: 200 OK code: 200 duration: 0s ``` -------------------------------------------------------------------------------- /testdata/fixtures/resource_TeamsResourceHandler_List All.yaml: -------------------------------------------------------------------------------- ```yaml --- version: 2 interactions: - id: 0 request: proto: HTTP/1.1 proto_major: 1 proto_minor: 1 content_length: 310 transfer_encoding: [] trailer: {} host: api.linear.app remote_addr: "" request_uri: "" 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"}' form: {} headers: Content-Type: - application/json url: https://api.linear.app/graphql method: POST response: proto: HTTP/2.0 proto_major: 2 proto_minor: 0 transfer_encoding: [] trailer: {} content_length: -1 uncompressed: true body: | {"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"}]}}]}}} headers: Alt-Svc: - h3=":443"; ma=86400 Cache-Control: - no-store Cf-Cache-Status: - DYNAMIC Content-Type: - application/json; charset=utf-8 Etag: - W/"210-+ISnhlSrm6Gd7LWWbqn3eOeSXhw" Server: - cloudflare Vary: - Accept-Encoding Via: - 1.1 google status: 200 OK code: 200 duration: 0s ``` -------------------------------------------------------------------------------- /testdata/fixtures/create_project_handler_Invalid team ID.yaml: -------------------------------------------------------------------------------- ```yaml --- version: 2 interactions: - id: 0 request: proto: HTTP/1.1 proto_major: 1 proto_minor: 1 content_length: 357 transfer_encoding: [] trailer: {} host: api.linear.app remote_addr: "" request_uri: "" 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"]}}}' form: {} headers: Content-Type: - application/json url: https://api.linear.app/graphql method: POST response: proto: HTTP/2.0 proto_major: 2 proto_minor: 0 transfer_encoding: [] trailer: {} content_length: -1 uncompressed: true body: | {"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} headers: Alt-Svc: - h3=":443"; ma=86400 Cache-Control: - no-store Cf-Cache-Status: - DYNAMIC Content-Type: - application/json; charset=utf-8 Etag: - W/"1e4-JRWXeyaRzkdssN5PwkmdFPPwZ6Y" Server: - cloudflare Vary: - Accept-Encoding Via: - 1.1 google status: 200 OK code: 200 duration: 0s ``` -------------------------------------------------------------------------------- /testdata/fixtures/get_teams_handler_Get Teams.yaml: -------------------------------------------------------------------------------- ```yaml --- version: 2 interactions: - id: 0 request: proto: HTTP/1.1 proto_major: 1 proto_minor: 1 content_length: 367 transfer_encoding: [] trailer: {} host: api.linear.app remote_addr: "" request_uri: "" 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"}}}}' form: {} headers: Content-Type: - application/json url: https://api.linear.app/graphql method: POST response: proto: HTTP/2.0 proto_major: 2 proto_minor: 0 transfer_encoding: [] trailer: {} content_length: -1 uncompressed: true body: | {"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"}]}}]}}} headers: Alt-Svc: - h3=":443"; ma=86400 Cache-Control: - no-store Cf-Cache-Status: - DYNAMIC Content-Type: - application/json; charset=utf-8 Etag: - W/"210-+ISnhlSrm6Gd7LWWbqn3eOeSXhw" Server: - cloudflare Vary: - Accept-Encoding Via: - 1.1 google status: 200 OK code: 200 duration: 0s ``` -------------------------------------------------------------------------------- /testdata/fixtures/create_milestone_handler_Invalid project ID.yaml: -------------------------------------------------------------------------------- ```yaml --- version: 2 interactions: - id: 0 request: proto: HTTP/1.1 proto_major: 1 proto_minor: 1 content_length: 440 transfer_encoding: [] trailer: {} host: api.linear.app remote_addr: "" request_uri: "" 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"}}}' form: {} headers: Content-Type: - application/json url: https://api.linear.app/graphql method: POST response: proto: HTTP/2.0 proto_major: 2 proto_minor: 0 transfer_encoding: [] trailer: {} content_length: -1 uncompressed: true body: | {"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} headers: Alt-Svc: - h3=":443"; ma=86400 Cache-Control: - no-store Cf-Cache-Status: - DYNAMIC Content-Type: - application/json; charset=utf-8 Etag: - W/"1df-TEVUGn11CxiIlKsKcBOVc2L/nlo" Server: - cloudflare Vary: - Accept-Encoding Via: - 1.1 google status: 200 OK code: 200 duration: 0s ``` -------------------------------------------------------------------------------- /pkg/linear/test_helpers.go: -------------------------------------------------------------------------------- ```go package linear import ( "os" "strings" "testing" "gopkg.in/dnaeon/go-vcr.v4/pkg/cassette" "gopkg.in/dnaeon/go-vcr.v4/pkg/recorder" ) // NewTestClient creates a LinearClient for testing // If record is true, it will record HTTP interactions // If record is false, it will replay recorded interactions func NewTestClient(t *testing.T, cassetteName string, record bool) (*LinearClient, func()) { if record { // Ensure API key is set when recording if os.Getenv("TEST_LINEAR_API_KEY") == "" { t.Fatal("TEST_LINEAR_API_KEY environment variable is required for recording") } } wipeAuthorizationHook := func(i *cassette.Interaction) error { delete(i.Request.Headers, "Authorization") delete(i.Response.Headers, "Set-Cookie") return nil } wipeChangingMetadataHook := func(i *cassette.Interaction) error { delete(i.Request.Headers, "User-Agent") delete(i.Response.Headers, "Cf-Ray") delete(i.Response.Headers, "Date") for k := range i.Response.Headers { if strings.HasPrefix(strings.ToLower(k), "x-") { delete(i.Response.Headers, k) } } i.Response.Duration = 0 return nil } // Create the recorder with appropriate mode options := []recorder.Option{ // don't record authorization header in cassettes recorder.WithHook(wipeAuthorizationHook, recorder.AfterCaptureHook), recorder.WithHook(wipeChangingMetadataHook, recorder.AfterCaptureHook), recorder.WithMatcher(cassette.NewDefaultMatcher(cassette.WithIgnoreAuthorization(), cassette.WithIgnoreUserAgent())), } if record { options = append(options, recorder.WithMode(recorder.ModeRecordOnly)) } else { options = append(options, recorder.WithMode(recorder.ModeReplayOnly)) } r, err := recorder.New("../../testdata/fixtures/"+cassetteName, options...) if err != nil { t.Fatalf("Failed to create recorder: %v", err) } // Create a Linear client that uses the recorder's HTTP client apiKey := os.Getenv("TEST_LINEAR_API_KEY") client := &LinearClient{ apiKey: apiKey, httpClient: r.GetDefaultClient(), rateLimiter: NewRateLimiter(1400), } // Return the client and a cleanup function cleanup := func() { r.Stop() } return client, cleanup } ``` -------------------------------------------------------------------------------- /testdata/fixtures/update_comment_handler_Valid comment update.yaml: -------------------------------------------------------------------------------- ```yaml --- version: 2 interactions: - id: 0 request: proto: HTTP/1.1 proto_major: 1 proto_minor: 1 content_length: 536 transfer_encoding: [] trailer: {} host: api.linear.app remote_addr: "" request_uri: "" 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"}}}' form: {} headers: Content-Type: - application/json url: https://api.linear.app/graphql method: POST response: proto: HTTP/2.0 proto_major: 2 proto_minor: 0 transfer_encoding: [] trailer: {} content_length: -1 uncompressed: true body: | {"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"}}}}} headers: Alt-Svc: - h3=":443"; ma=86400 Cache-Control: - no-store Cf-Cache-Status: - DYNAMIC Content-Type: - application/json; charset=utf-8 Etag: - W/"20f-QmoB3WjGuI3Kn4PiWkTFI7/hRj8" Server: - cloudflare Vary: - Accept-Encoding Via: - 1.1 google status: 200 OK code: 200 duration: 0s ``` -------------------------------------------------------------------------------- /pkg/tools/update_issue_comment.go: -------------------------------------------------------------------------------- ```go package tools import ( "context" "fmt" "github.com/geropl/linear-mcp-go/pkg/linear" "github.com/mark3labs/mcp-go/mcp" ) // UpdateCommentTool is the tool definition for updating a comment var UpdateCommentTool = mcp.NewTool("linear_update_issue_comment", mcp.WithDescription("Updates an existing comment on a Linear issue."), mcp.WithString("comment", mcp.Required(), mcp.Description("Comment identifier to update. Accepts: full URL, UUID, shorthand (comment-abc123), or hash (abc123).")), mcp.WithString("body", mcp.Required(), mcp.Description("New comment text in markdown format")), ) // UpdateCommentHandler handles the linear_update_comment tool func UpdateCommentHandler(linearClient *linear.LinearClient) func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { // Extract arguments commentIdentifier, err := request.RequireString("comment") if err != nil { return &mcp.CallToolResult{IsError: true, Content: []mcp.Content{mcp.TextContent{Type: "text", Text: err.Error()}}}, nil } // Resolve comment identifier to a UUID commentID, err := resolveCommentIdentifier(linearClient, commentIdentifier) if err != nil { return &mcp.CallToolResult{IsError: true, Content: []mcp.Content{mcp.TextContent{Type: "text", Text: fmt.Sprintf("Failed to resolve comment: %v", err)}}}, nil } body, err := request.RequireString("body") if err != nil { return &mcp.CallToolResult{IsError: true, Content: []mcp.Content{mcp.TextContent{Type: "text", Text: err.Error()}}}, nil } // Update the comment input := linear.UpdateCommentInput{ CommentID: commentID, Body: body, } comment, issue, err := linearClient.UpdateComment(input) if err != nil { return &mcp.CallToolResult{IsError: true, Content: []mcp.Content{mcp.TextContent{Type: "text", Text: fmt.Sprintf("Failed to update comment: %v", err)}}}, nil } // Return the result resultText := fmt.Sprintf("Updated comment on %s\n", formatIssueIdentifier(issue)) resultText += fmt.Sprintf("Comment ID: %s\n", comment.ID) resultText += fmt.Sprintf("Thread (for replies): %s\n", comment.ID) resultText += fmt.Sprintf("URL: %s", comment.URL) return &mcp.CallToolResult{Content: []mcp.Content{mcp.TextContent{Type: "text", Text: resultText}}}, nil } } ``` -------------------------------------------------------------------------------- /testdata/fixtures/create_issue_handler_Valid issue with teamId.yaml: -------------------------------------------------------------------------------- ```yaml --- version: 2 interactions: - id: 0 request: proto: HTTP/1.1 proto_major: 1 proto_minor: 1 content_length: 686 transfer_encoding: [] trailer: {} host: api.linear.app remote_addr: "" request_uri: "" 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"}}}' form: {} headers: Content-Type: - application/json User-Agent: - linear-mcp-go/1.0.0 url: https://api.linear.app/graphql method: POST response: proto: HTTP/2.0 proto_major: 2 proto_minor: 0 transfer_encoding: [] trailer: {} content_length: -1 uncompressed: true body: | {"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":[]}}}}} headers: Alt-Svc: - h3=":443"; ma=86400 Cache-Control: - no-store Cf-Cache-Status: - DYNAMIC Content-Type: - application/json; charset=utf-8 Etag: - W/"1f3-Uv10fBIw9AkcaiDdPamGO+YgS4U" Server: - cloudflare Vary: - Accept-Encoding Via: - 1.1 google status: 200 OK code: 200 duration: 0s ``` -------------------------------------------------------------------------------- /testdata/fixtures/create_issue_handler_Valid issue with team.yaml: -------------------------------------------------------------------------------- ```yaml --- version: 2 interactions: - id: 0 request: proto: HTTP/1.1 proto_major: 1 proto_minor: 1 content_length: 831 transfer_encoding: [] trailer: {} host: api.linear.app remote_addr: "" request_uri: "" 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"}}}' form: {} headers: Content-Type: - application/json url: https://api.linear.app/graphql method: POST response: proto: HTTP/2.0 proto_major: 2 proto_minor: 0 transfer_encoding: [] trailer: {} content_length: -1 uncompressed: true body: | {"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}}}} headers: Alt-Svc: - h3=":443"; ma=86400 Cache-Control: - no-store Cf-Cache-Status: - DYNAMIC Content-Type: - application/json; charset=utf-8 Etag: - W/"21a-riLwqmexbAhReQeBEf18rQ70w9Y" Server: - cloudflare Vary: - Accept-Encoding Via: - 1.1 google status: 200 OK code: 200 duration: 0s ``` -------------------------------------------------------------------------------- /memory-bank/productContext.md: -------------------------------------------------------------------------------- ```markdown # Product Context: Linear MCP Server ## Why This Project Exists 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. ## Problems It Solves 1. **Integration Complexity**: Simplifies the process of connecting AI assistants to Linear by providing a standardized interface. 2. **API Consistency**: Abstracts away the complexities of the Linear API, providing a consistent experience. 3. **Rate Limiting**: Handles Linear's API rate limits automatically, preventing quota exhaustion. 4. **Authentication Management**: Manages API key authentication in a secure manner. 5. **Error Handling**: Provides meaningful error messages when operations fail. ## How It Should Work 1. **Server Initialization**: - The server starts and listens for MCP requests on stdin/stdout. - It validates the LINEAR_API_KEY environment variable. - It registers all available tools with the MCP server. 2. **Tool Execution**: - When a tool is called (e.g., linear_create_issue), the server validates the input parameters. - It translates the request into appropriate Linear API calls. - It handles the response, formatting it according to MCP specifications. - It returns the result to the caller. 3. **Error Scenarios**: - If the API key is missing or invalid, it returns a clear error message. - If required parameters are missing, it returns parameter validation errors. - If the Linear API returns an error, it translates and returns it in a user-friendly format. - If rate limits are exceeded, it handles backoff and retries appropriately. ## User Experience Goals 1. **Simplicity**: Users should be able to set up and use the server with minimal configuration. 2. **Reliability**: The server should handle errors gracefully and provide clear feedback. 3. **Completeness**: All common Linear operations should be supported. 4. **Performance**: Operations should be efficient and respect API rate limits. 5. **Documentation**: Clear documentation should be provided for all tools and setup procedures. ## Integration Points 1. **Linear API**: The server interacts with Linear's API to perform operations. 2. **MCP Protocol**: The server implements the MCP protocol to communicate with AI assistants. 3. **Environment**: The server uses environment variables for configuration. ``` -------------------------------------------------------------------------------- /testdata/fixtures/search_projects_handler_Multiple results.yaml: -------------------------------------------------------------------------------- ```yaml --- version: 2 interactions: - id: 0 request: proto: HTTP/1.1 proto_major: 1 proto_minor: 1 content_length: 541 transfer_encoding: [] trailer: {} host: api.linear.app remote_addr: "" request_uri: "" 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"}}}}' form: {} headers: Content-Type: - application/json url: https://api.linear.app/graphql method: POST response: proto: HTTP/2.0 proto_major: 2 proto_minor: 0 transfer_encoding: [] trailer: {} content_length: -1 uncompressed: true body: | {"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"}]}}} headers: Alt-Svc: - h3=":443"; ma=86400 Cache-Control: - no-store Cf-Cache-Status: - DYNAMIC Content-Type: - application/json; charset=utf-8 Etag: - W/"365-vV3AiEsYIfWAgp5Ebe03XF87/Kw" Server: - cloudflare Vary: - Accept-Encoding Via: - 1.1 google status: 200 OK code: 200 duration: 0s ``` -------------------------------------------------------------------------------- /testdata/fixtures/create_issue_handler_Valid issue with team UUID.yaml: -------------------------------------------------------------------------------- ```yaml --- version: 2 interactions: - id: 0 request: proto: HTTP/1.1 proto_major: 1 proto_minor: 1 content_length: 846 transfer_encoding: [] trailer: {} host: api.linear.app remote_addr: "" request_uri: "" 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"}}}' form: {} headers: Content-Type: - application/json url: https://api.linear.app/graphql method: POST response: proto: HTTP/2.0 proto_major: 2 proto_minor: 0 transfer_encoding: [] trailer: {} content_length: -1 uncompressed: true body: | {"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}}}} headers: Alt-Svc: - h3=":443"; ma=86400 Cache-Control: - no-store Cf-Cache-Status: - DYNAMIC Content-Type: - application/json; charset=utf-8 Etag: - W/"238-LjhPlWjTFDB/d+FI0JgaJxLeSPw" Server: - cloudflare Vary: - Accept-Encoding Via: - 1.1 google status: 200 OK code: 200 duration: 0s ``` -------------------------------------------------------------------------------- /testdata/fixtures/create_issue_handler_Create sub issue.yaml: -------------------------------------------------------------------------------- ```yaml --- version: 2 interactions: - id: 0 request: proto: HTTP/1.1 proto_major: 1 proto_minor: 1 content_length: 880 transfer_encoding: [] trailer: {} host: api.linear.app remote_addr: "" request_uri: "" 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"}}}' form: {} headers: Content-Type: - application/json url: https://api.linear.app/graphql method: POST response: proto: HTTP/2.0 proto_major: 2 proto_minor: 0 transfer_encoding: [] trailer: {} content_length: -1 uncompressed: true body: | {"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}}}} headers: Alt-Svc: - h3=":443"; ma=86400 Cache-Control: - no-store Cf-Cache-Status: - DYNAMIC Content-Type: - application/json; charset=utf-8 Etag: - W/"218-Ctac8rozKoI8dD6f8c2wIWoJnE4" Server: - cloudflare Vary: - Accept-Encoding Via: - 1.1 google status: 200 OK code: 200 duration: 0s ``` -------------------------------------------------------------------------------- /pkg/tools/get_user_issues.go: -------------------------------------------------------------------------------- ```go package tools import ( "context" "fmt" "github.com/geropl/linear-mcp-go/pkg/linear" "github.com/mark3labs/mcp-go/mcp" ) // GetUserIssuesTool is the tool definition for getting user issues var GetUserIssuesTool = mcp.NewTool("linear_get_user_issues", mcp.WithDescription("Retrieves issues assigned to a user."), mcp.WithString("user", mcp.Description("Optional user identifier (UUID, name, or email). If not provided, returns authenticated user's issues")), mcp.WithBoolean("includeArchived", mcp.Description("Include archived issues in results")), mcp.WithNumber("limit", mcp.Description("Maximum number of issues to return (default: 50)")), ) // GetUserIssuesHandler handles the linear_get_user_issues tool func GetUserIssuesHandler(linearClient *linear.LinearClient) func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { // Build input input := linear.GetUserIssuesInput{} if user, err := request.RequireString("user"); err == nil && user != "" { // Resolve user identifier to a user ID userID, err := resolveUserIdentifier(linearClient, user) if err != nil { return &mcp.CallToolResult{IsError: true, Content: []mcp.Content{mcp.TextContent{Type: "text", Text: fmt.Sprintf("Failed to resolve user: %v", err)}}}, nil } input.UserID = userID } input.IncludeArchived = request.GetBool("includeArchived", false) input.Limit = request.GetInt("limit", 50) // Get user issues issues, err := linearClient.GetUserIssues(input) if err != nil { return &mcp.CallToolResult{IsError: true, Content: []mcp.Content{mcp.TextContent{Type: "text", Text: fmt.Sprintf("Failed to get user issues: %v", err)}}}, nil } // Format the result resultText := fmt.Sprintf("Found %d issues:\n", len(issues)) for _, issue := range issues { // Create a temporary Issue object to use with formatIssueIdentifier tempIssue := &linear.Issue{ ID: issue.ID, Identifier: issue.Identifier, } statusStr := "None" if issue.Status != "" { statusStr = issue.Status } else if issue.StateName != "" { statusStr = issue.StateName } resultText += fmt.Sprintf("- %s\n", formatIssueIdentifier(tempIssue)) resultText += fmt.Sprintf(" Title: %s\n", issue.Title) resultText += fmt.Sprintf(" Priority: %s\n", priorityToString(issue.Priority)) resultText += fmt.Sprintf(" Status: %s\n", statusStr) resultText += fmt.Sprintf(" URL: %s\n", issue.URL) } return &mcp.CallToolResult{Content: []mcp.Content{mcp.TextContent{Type: "text", Text: resultText}}}, nil } } ``` -------------------------------------------------------------------------------- /testdata/fixtures/create_issue_handler_Create issue with project ID.yaml: -------------------------------------------------------------------------------- ```yaml --- version: 2 interactions: - id: 0 request: proto: HTTP/1.1 proto_major: 1 proto_minor: 1 content_length: 893 transfer_encoding: [] trailer: {} host: api.linear.app remote_addr: "" request_uri: "" 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"}}}' form: {} headers: Content-Type: - application/json url: https://api.linear.app/graphql method: POST response: proto: HTTP/2.0 proto_major: 2 proto_minor: 0 transfer_encoding: [] trailer: {} content_length: -1 uncompressed: true body: | {"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}}}} headers: Alt-Svc: - h3=":443"; ma=86400 Cache-Control: - no-store Cf-Cache-Status: - DYNAMIC Content-Type: - application/json; charset=utf-8 Etag: - W/"279-SjgXENxrlDWv72SYhs5pyB6uKnI" Server: - cloudflare Vary: - Accept-Encoding Via: - 1.1 google status: 200 OK code: 200 duration: 0s ``` -------------------------------------------------------------------------------- /testdata/fixtures/get_project_handler_By ID.yaml: -------------------------------------------------------------------------------- ```yaml --- version: 2 interactions: - id: 0 request: proto: HTTP/1.1 proto_major: 1 proto_minor: 1 content_length: 733 transfer_encoding: [] trailer: {} host: api.linear.app remote_addr: "" request_uri: "" 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"}}' form: {} headers: Content-Type: - application/json url: https://api.linear.app/graphql method: POST response: proto: HTTP/2.0 proto_major: 2 proto_minor: 0 transfer_encoding: [] trailer: {} content_length: -1 uncompressed: true body: | {"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"}}} headers: Alt-Svc: - h3=":443"; ma=86400 Cache-Control: - no-store Cf-Cache-Status: - DYNAMIC Content-Type: - application/json; charset=utf-8 Etag: - W/"348-ZpF/ZpfaL+aW46aIruU9iQ4HbXY" Server: - cloudflare Vary: - Accept-Encoding Via: - 1.1 google status: 200 OK code: 200 duration: 0s ``` -------------------------------------------------------------------------------- /pkg/tools/reply_to_comment.go: -------------------------------------------------------------------------------- ```go package tools import ( "context" "fmt" "github.com/geropl/linear-mcp-go/pkg/linear" "github.com/mark3labs/mcp-go/mcp" ) // ReplyToCommentTool is a specialized tool for replying to comments var ReplyToCommentTool = mcp.NewTool("linear_reply_to_comment", mcp.WithDescription("Reply to an existing comment on a Linear issue."), mcp.WithString("thread", mcp.Required(), mcp.Description("Comment to reply to. Accepts: full URL, UUID, shorthand (comment-abc123), or hash (abc123).")), mcp.WithString("body", mcp.Required(), mcp.Description("Reply text in markdown format")), mcp.WithString("createAsUser", mcp.Description("Optional custom username to show for the reply")), ) // ReplyToCommentHandler handles the linear_reply_to_comment tool func ReplyToCommentHandler(linearClient *linear.LinearClient) func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { // Extract arguments threadIdentifier, err := request.RequireString("thread") if err != nil { return &mcp.CallToolResult{IsError: true, Content: []mcp.Content{mcp.TextContent{Type: "text", Text: err.Error()}}}, nil } body, err := request.RequireString("body") if err != nil { return &mcp.CallToolResult{IsError: true, Content: []mcp.Content{mcp.TextContent{Type: "text", Text: err.Error()}}}, nil } createAsUser := request.GetString("createAsUser", "") // Resolve the parent comment to get its UUID parentCommentID, err := resolveCommentIdentifier(linearClient, threadIdentifier) if err != nil { return &mcp.CallToolResult{IsError: true, Content: []mcp.Content{mcp.TextContent{Type: "text", Text: fmt.Sprintf("Failed to resolve comment: %v", err)}}}, nil } // Get the parent comment to find its issue parentComment, err := linearClient.GetComment(parentCommentID) if err != nil { return &mcp.CallToolResult{IsError: true, Content: []mcp.Content{mcp.TextContent{Type: "text", Text: fmt.Sprintf("Failed to get parent comment: %v", err)}}}, nil } if parentComment.Issue == nil { return &mcp.CallToolResult{IsError: true, Content: []mcp.Content{mcp.TextContent{Type: "text", Text: "Parent comment does not have an associated issue"}}}, nil } // Add the reply input := linear.AddCommentInput{ IssueID: parentComment.Issue.ID, Body: body, CreateAsUser: createAsUser, ParentID: parentCommentID, } comment, issue, err := linearClient.AddComment(input) if err != nil { return &mcp.CallToolResult{IsError: true, Content: []mcp.Content{mcp.TextContent{Type: "text", Text: fmt.Sprintf("Failed to add reply: %v", err)}}}, nil } // Return the result using the unified format resultText := formatNewComment(comment, issue, parentCommentID) return &mcp.CallToolResult{Content: []mcp.Content{mcp.TextContent{Type: "text", Text: resultText}}}, nil } } ``` -------------------------------------------------------------------------------- /testdata/fixtures/create_issue_handler_Valid issue.yaml: -------------------------------------------------------------------------------- ```yaml --- version: 2 interactions: - id: 0 request: proto: HTTP/1.1 proto_major: 1 proto_minor: 1 content_length: 579 transfer_encoding: [] trailer: {} host: api.linear.app remote_addr: "" request_uri: "" 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"}}}' form: {} headers: Content-Type: - application/json User-Agent: - linear-mcp-go/1.0.0 url: https://api.linear.app/graphql method: POST response: proto: HTTP/2.0 proto_major: 2 proto_minor: 0 transfer_encoding: [] trailer: {} content_length: -1 uncompressed: true body: | {"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"}}}}} headers: Alt-Svc: - h3=":443"; ma=86400 Cache-Control: - no-store Cf-Cache-Status: - DYNAMIC Cf-Ray: - 91a8d3ad484f8fee-FRA Content-Type: - application/json; charset=utf-8 Date: - Mon, 03 Mar 2025 11:34:49 GMT Etag: - W/"1dd-e9YqnIA3F4HsF8LOEx21H1J0EIg" Server: - cloudflare Vary: - Accept-Encoding Via: - 1.1 google X-Complexity: - "6" X-Ratelimit-Complexity-Limit: - "3000000" X-Ratelimit-Complexity-Remaining: - "2996924" X-Ratelimit-Complexity-Reset: - "1741005289233" X-Ratelimit-Requests-Limit: - "1500" X-Ratelimit-Requests-Remaining: - "1498" X-Ratelimit-Requests-Reset: - "1741005289233" X-Request-Id: - 91a8d3ad73e58fee-FRA status: 200 OK code: 200 duration: 166.601029ms ``` -------------------------------------------------------------------------------- /pkg/server/test_helpers.go: -------------------------------------------------------------------------------- ```go package server import ( "flag" "os" "path/filepath" "testing" "gopkg.in/yaml.v3" ) var record = flag.Bool("record", false, "Record HTTP interactions (excluding writes)") var recordWrites = flag.Bool("recordWrites", false, "Record HTTP interactions (incl. writes)") var golden = flag.Bool("golden", false, "Update all golden files and recordings") // Shared constants for tests const ( TEAM_NAME = "Test Team" TEAM_KEY = "TEST" TEAM_ID = "234c5451-a839-4c8f-98d9-da00973f1060" ISSUE_ID = "TEST-10" COMMENT_ISSUE_ID = "TEST-12" // Used for testing add_comment handler USER_ID = "cc24eee4-9edc-4bfe-b91b-fedde125ba85" PROJECT_ID = "01bff2dd-ab7f-4464-b425-97073862013f" UPDATE_PROJECT_ID = "bfa49864-16c9-44db-994e-a11ba2b386f1" MILESTONE_ID = "c86acc00-3035-4a67-82f2-2a5bf6453e92" UPDATE_MILESTONE_ID = "2d95299d-1341-484b-ab00-5cb587f2cc67" INITIATIVE_ID = "3bb752a7-897e-4240-9306-01e48872fab3" UPDATE_INITIATIVE_ID = "c6a7dd0c-cbe2-4101-906d-ddd97acb2241" ) // expectation defines the expected output and error for a test case // For resource tests, Output will store the JSON representation of []mcp.ResourceContents type expectation struct { Err string `yaml:"err"` // Empty string means no error expected Output string `yaml:"output", flow` // Expected complete output } // readGoldenFile reads an expectation from a golden file func readGoldenFile(t *testing.T, path string) expectation { t.Helper() // Check if the golden file exists if _, err := os.Stat(path); os.IsNotExist(err) { // If the file doesn't exist, return an empty expectation // This allows tests to pass initially when golden files are missing, // prompting the user to run with -golden* flags to create them. t.Logf("Golden file %s does not exist. Run with appropriate -golden* flag to create it.", path) return expectation{} } // Read the golden file data, err := os.ReadFile(path) if err != nil { t.Fatalf("Failed to read golden file %s: %v", path, err) } // Parse the golden file var exp expectation if err := yaml.Unmarshal(data, &exp); err != nil { // If unmarshalling fails, treat it as an empty expectation // This handles cases where the golden file might be corrupted or empty t.Logf("Failed to parse golden file %s: %v. Treating as empty.", path, err) return expectation{} } return exp } // writeGoldenFile writes an expectation to a golden file func writeGoldenFile(t *testing.T, path string, exp expectation) { t.Helper() // Create the directory if it doesn't exist dir := filepath.Dir(path) if err := os.MkdirAll(dir, 0755); err != nil { t.Fatalf("Failed to create directory %s: %v", dir, err) } // Marshal the YAML node data, err := yaml.Marshal(&exp) if err != nil { t.Fatalf("Failed to marshal expectation: %v", err) } // Write the golden file if err := os.WriteFile(path, data, 0644); err != nil { t.Fatalf("Failed to write golden file %s: %v", path, err) } t.Logf("Successfully wrote golden file: %s", path) } ``` -------------------------------------------------------------------------------- /pkg/tools/add_comment.go: -------------------------------------------------------------------------------- ```go package tools import ( "context" "fmt" "github.com/geropl/linear-mcp-go/pkg/linear" "github.com/mark3labs/mcp-go/mcp" ) // AddCommentTool is the tool definition for adding a comment var AddCommentTool = mcp.NewTool("linear_add_comment", 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'."), mcp.WithString("issue", mcp.Required(), mcp.Description("ID or identifier (e.g., 'TEAM-123') of the issue to comment on")), 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.")), mcp.WithString("body", mcp.Required(), mcp.Description("Comment text in markdown format")), mcp.WithString("createAsUser", mcp.Description("Optional custom username to show for the comment")), ) // AddCommentHandler handles the linear_add_comment tool func AddCommentHandler(linearClient *linear.LinearClient) func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { // Extract arguments issueIdentifier, err := request.RequireString("issue") if err != nil { return &mcp.CallToolResult{IsError: true, Content: []mcp.Content{mcp.TextContent{Type: "text", Text: err.Error()}}}, nil } // Resolve issue identifier to a UUID issueID, err := resolveIssueIdentifier(linearClient, issueIdentifier) if err != nil { return &mcp.CallToolResult{IsError: true, Content: []mcp.Content{mcp.TextContent{Type: "text", Text: fmt.Sprintf("Failed to resolve issue: %v", err)}}}, nil } body, err := request.RequireString("body") if err != nil { return &mcp.CallToolResult{IsError: true, Content: []mcp.Content{mcp.TextContent{Type: "text", Text: err.Error()}}}, nil } // Extract optional arguments createAsUser := request.GetString("createAsUser", "") threadIdentifier := request.GetString("thread", "") // Resolve thread identifier to UUID if provided var parentID string if threadIdentifier != "" { resolvedParentID, err := resolveCommentIdentifier(linearClient, threadIdentifier) if err != nil { return &mcp.CallToolResult{IsError: true, Content: []mcp.Content{mcp.TextContent{Type: "text", Text: fmt.Sprintf("Failed to resolve thread comment: %v", err)}}}, nil } parentID = resolvedParentID } // Add the comment input := linear.AddCommentInput{ IssueID: issueID, Body: body, CreateAsUser: createAsUser, ParentID: parentID, } comment, issue, err := linearClient.AddComment(input) if err != nil { return &mcp.CallToolResult{IsError: true, Content: []mcp.Content{mcp.TextContent{Type: "text", Text: fmt.Sprintf("Failed to add comment: %v", err)}}}, nil } // Return the result using the unified format resultText := formatNewComment(comment, issue, parentID) return &mcp.CallToolResult{Content: []mcp.Content{mcp.TextContent{Type: "text", Text: resultText}}}, nil } } ``` -------------------------------------------------------------------------------- /testdata/fixtures/get_user_issues_handler_Specific user issues.yaml: -------------------------------------------------------------------------------- ```yaml --- version: 2 interactions: - id: 0 request: proto: HTTP/1.1 proto_major: 1 proto_minor: 1 content_length: 556 transfer_encoding: [] trailer: {} host: api.linear.app remote_addr: "" request_uri: "" 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"}}' form: {} headers: Content-Type: - application/json url: https://api.linear.app/graphql method: POST response: proto: HTTP/2.0 proto_major: 2 proto_minor: 0 transfer_encoding: [] trailer: {} content_length: -1 uncompressed: true 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" headers: Alt-Svc: - h3=":443"; ma=86400 Cache-Control: - no-store Cf-Cache-Status: - DYNAMIC Content-Type: - application/json; charset=utf-8 Etag: - W/"52b-iwXdi0Au8LYVFWrptXXwpSz7HVA" Server: - cloudflare Vary: - Accept-Encoding Via: - 1.1 google status: 200 OK code: 200 duration: 0s ``` -------------------------------------------------------------------------------- /testdata/fixtures/get_initiative_handler_Non-existent name.yaml: -------------------------------------------------------------------------------- ```yaml --- version: 2 interactions: - id: 0 request: proto: HTTP/1.1 proto_major: 1 proto_minor: 1 content_length: 201 transfer_encoding: [] trailer: {} host: api.linear.app remote_addr: "" request_uri: "" 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"}}' form: {} headers: Content-Type: - application/json url: https://api.linear.app/graphql method: POST response: proto: HTTP/2.0 proto_major: 2 proto_minor: 0 transfer_encoding: [] trailer: {} content_length: -1 uncompressed: true body: | {"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} headers: Alt-Svc: - h3=":443"; ma=86400 Cache-Control: - no-store Cf-Cache-Status: - DYNAMIC Content-Type: - application/json; charset=utf-8 Etag: - W/"11c-c44P18yu0Ek2xjOBk4Ycpqr5fPg" Server: - cloudflare Vary: - Accept-Encoding Via: - 1.1 google status: 200 OK code: 200 duration: 0s - id: 1 request: proto: HTTP/1.1 proto_major: 1 proto_minor: 1 content_length: 295 transfer_encoding: [] trailer: {} host: api.linear.app remote_addr: "" request_uri: "" 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"}}}}' form: {} headers: Content-Type: - application/json url: https://api.linear.app/graphql method: POST response: proto: HTTP/2.0 proto_major: 2 proto_minor: 0 transfer_encoding: [] trailer: {} content_length: 38 uncompressed: false body: | {"data":{"initiatives":{"nodes":[]}}} headers: Alt-Svc: - h3=":443"; ma=86400 Cache-Control: - no-store Cf-Cache-Status: - DYNAMIC Content-Length: - "38" Content-Type: - application/json; charset=utf-8 Etag: - W/"26-1+AHGSycMEc+rIWhJuNJZIAom5A" Server: - cloudflare Vary: - Accept-Encoding Via: - 1.1 google status: 200 OK code: 200 duration: 0s ``` -------------------------------------------------------------------------------- /testdata/fixtures/update_initiative_handler_Non-existent initiative.yaml: -------------------------------------------------------------------------------- ```yaml --- version: 2 interactions: - id: 0 request: proto: HTTP/1.1 proto_major: 1 proto_minor: 1 content_length: 207 transfer_encoding: [] trailer: {} host: api.linear.app remote_addr: "" request_uri: "" 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"}}' form: {} headers: Content-Type: - application/json url: https://api.linear.app/graphql method: POST response: proto: HTTP/2.0 proto_major: 2 proto_minor: 0 transfer_encoding: [] trailer: {} content_length: -1 uncompressed: true body: | {"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} headers: Alt-Svc: - h3=":443"; ma=86400 Cache-Control: - no-store Cf-Cache-Status: - DYNAMIC Content-Type: - application/json; charset=utf-8 Etag: - W/"11c-c44P18yu0Ek2xjOBk4Ycpqr5fPg" Server: - cloudflare Vary: - Accept-Encoding Via: - 1.1 google status: 200 OK code: 200 duration: 0s - id: 1 request: proto: HTTP/1.1 proto_major: 1 proto_minor: 1 content_length: 301 transfer_encoding: [] trailer: {} host: api.linear.app remote_addr: "" request_uri: "" 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"}}}}' form: {} headers: Content-Type: - application/json url: https://api.linear.app/graphql method: POST response: proto: HTTP/2.0 proto_major: 2 proto_minor: 0 transfer_encoding: [] trailer: {} content_length: 38 uncompressed: false body: | {"data":{"initiatives":{"nodes":[]}}} headers: Alt-Svc: - h3=":443"; ma=86400 Cache-Control: - no-store Cf-Cache-Status: - DYNAMIC Content-Length: - "38" Content-Type: - application/json; charset=utf-8 Etag: - W/"26-1+AHGSycMEc+rIWhJuNJZIAom5A" Server: - cloudflare Vary: - Accept-Encoding Via: - 1.1 google status: 200 OK code: 200 duration: 0s ``` -------------------------------------------------------------------------------- /scripts/register-cline.sh: -------------------------------------------------------------------------------- ```bash #!/bin/bash # This script installs the Linear MCP server and registers it for use with the cline VSCode extension: https://github.com/cline/cline # Note: to use this, you need to have a) cline installed, and b) set LINEAR_API_KEY in your environment # # Usage: # ./register-cline.sh linear-api-key [write-access] # # Parameters: # linear-api-key: Mandatory. The Linear API key to use for the MCP server. # write-access: Optional. Set to "true" to enable write operations. Default is "false". # # Examples: # ./register-cline.sh # Install with read-only mode (default) # ./register-cline.sh true # Install with write operations enabled # LINEAR_API_KEY (mandatory) LINEAR_API_KEY=$1 if [ -z "$LINEAR_API_KEY" ]; then echo "LINEAR_API_KEY is not set, skipping setup." exit 1 fi # Get the write-access parameter (default: false) WRITE_ACCESS=${2:-false} MCP_SERVERS_DIR="$HOME/mcp-servers" mkdir -p $MCP_SERVERS_DIR # Check if the Linear MCP server binary is on the path already LINEAR_MCP_BINARY="$(which linear-mcp-go)" if [ -z "$LINEAR_MCP_BINARY" ]; then echo "Did not find linear-mcp-go on the path, installing from latest GitHub release..." # This fetches information about the latest release to determine the download URL LATEST_RELEASE=$(curl -s https://api.github.com/repos/geropl/linear-mcp-go/releases/latest) # Extract the download URL for the Linux binary DOWNLOAD_URL=$(echo $LATEST_RELEASE | jq -r '.assets[] | select(.name | contains("linux")) | .browser_download_url') if [ -z "$DOWNLOAD_URL" ]; then echo "Error: Could not find Linux binary in the latest release" exit 1 fi # Download the Linear MCP server binary echo "Downloading Linear MCP server from $DOWNLOAD_URL..." curl -L -o $MCP_SERVERS_DIR/linear-mcp-go $DOWNLOAD_URL # Make the binary executable chmod +x $MCP_SERVERS_DIR/linear-mcp-go echo "Linear MCP server installed successfully at $MCP_SERVERS_DIR/linear-mcp-go" LINEAR_MCP_BINARY="$MCP_SERVERS_DIR/linear-mcp-go" fi # Configure cline to use the MCP server # This is where Cline looks for MCP server configurations CLINE_CONFIG_DIR="$HOME/.vscode-server/data/User/globalStorage/saoudrizwan.claude-dev/settings" mkdir -p "$CLINE_CONFIG_DIR" CLINE_MCP_SETTINGS="$CLINE_CONFIG_DIR/cline_mcp_settings.json" # Determine args based on write-access parameter if [ "$WRITE_ACCESS" = "true" ]; then SERVER_ARGS='["serve", "--write-access=true"]' else SERVER_ARGS='["serve"]' fi # Merge the existing settings with the new MCP server configuration cat <<EOF > "$CLINE_MCP_SETTINGS.new" { "mcpServers": { "linear": { "command": "$LINEAR_MCP_BINARY", "args": $SERVER_ARGS, "env": { "LINEAR_API_KEY": "$LINEAR_API_KEY" }, "disabled": false, "autoApprove": [] } } } EOF if [ -f "$CLINE_MCP_SETTINGS" ]; then echo "Found existing Cline MCP settings at $CLINE_MCP_SETTINGS" echo "Merging with new MCP server configuration..." jq -s '.[0] * .[1]' "$CLINE_MCP_SETTINGS" "$CLINE_MCP_SETTINGS.new" > "$CLINE_MCP_SETTINGS.tmp" mv "$CLINE_MCP_SETTINGS.tmp" "$CLINE_MCP_SETTINGS" else mv "$CLINE_MCP_SETTINGS.new" "$CLINE_MCP_SETTINGS" fi rm -f "$CLINE_MCP_SETTINGS.new" echo "Cline MCP settings updated at $CLINE_MCP_SETTINGS" ``` -------------------------------------------------------------------------------- /testdata/fixtures/get_initiative_handler_By name.yaml: -------------------------------------------------------------------------------- ```yaml --- version: 2 interactions: - id: 0 request: proto: HTTP/1.1 proto_major: 1 proto_minor: 1 content_length: 196 transfer_encoding: [] trailer: {} host: api.linear.app remote_addr: "" request_uri: "" 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"}}' form: {} headers: Content-Type: - application/json url: https://api.linear.app/graphql method: POST response: proto: HTTP/2.0 proto_major: 2 proto_minor: 0 transfer_encoding: [] trailer: {} content_length: -1 uncompressed: true body: | {"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} headers: Alt-Svc: - h3=":443"; ma=86400 Cache-Control: - no-store Cf-Cache-Status: - DYNAMIC Content-Type: - application/json; charset=utf-8 Etag: - W/"11c-c44P18yu0Ek2xjOBk4Ycpqr5fPg" Server: - cloudflare Vary: - Accept-Encoding Via: - 1.1 google status: 200 OK code: 200 duration: 0s - id: 1 request: proto: HTTP/1.1 proto_major: 1 proto_minor: 1 content_length: 290 transfer_encoding: [] trailer: {} host: api.linear.app remote_addr: "" request_uri: "" 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"}}}}' form: {} headers: Content-Type: - application/json url: https://api.linear.app/graphql method: POST response: proto: HTTP/2.0 proto_major: 2 proto_minor: 0 transfer_encoding: [] trailer: {} content_length: -1 uncompressed: true body: | {"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"}]}}} headers: Alt-Svc: - h3=":443"; ma=86400 Cache-Control: - no-store Cf-Cache-Status: - DYNAMIC Content-Type: - application/json; charset=utf-8 Etag: - W/"cf-Y4qKSrkoz5y3ppN8YkaP7va+sEU" Server: - cloudflare Vary: - Accept-Encoding Via: - 1.1 google status: 200 OK code: 200 duration: 0s ``` -------------------------------------------------------------------------------- /pkg/tools/update_issue.go: -------------------------------------------------------------------------------- ```go package tools import ( "context" "fmt" "github.com/geropl/linear-mcp-go/pkg/linear" "github.com/mark3labs/mcp-go/mcp" ) // UpdateIssueTool is the tool definition for updating issues var UpdateIssueTool = mcp.NewTool("linear_update_issue", mcp.WithDescription("Updates an existing Linear issue."), mcp.WithString("issue", mcp.Required(), mcp.Description("Issue ID or identifier (e.g., 'TEAM-123')")), mcp.WithString("title", mcp.Description("New title")), mcp.WithString("description", mcp.Description("New description")), mcp.WithString("priority", getPriorityOptions()...), mcp.WithString("status", mcp.Description("New status")), mcp.WithString("team", mcp.Description("New team (UUID, name, or key)")), mcp.WithString("projectId", mcp.Description("New project ID")), mcp.WithString("milestoneId", mcp.Description("New milestone ID")), ) // UpdateIssueHandler handles the linear_update_issue tool func UpdateIssueHandler(linearClient *linear.LinearClient) func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { // Extract arguments issueIdentifier, err := request.RequireString("issue") if err != nil { return &mcp.CallToolResult{IsError: true, Content: []mcp.Content{mcp.TextContent{Type: "text", Text: err.Error()}}}, nil } // Resolve issue identifier to a UUID id, err := resolveIssueIdentifier(linearClient, issueIdentifier) if err != nil { return &mcp.CallToolResult{IsError: true, Content: []mcp.Content{mcp.TextContent{Type: "text", Text: fmt.Sprintf("Failed to resolve issue: %v", err)}}}, nil } // Extract optional arguments title := request.GetString("title", "") description := request.GetString("description", "") var priority *int if priorityStr, err := request.RequireString("priority"); err == nil && priorityStr != "" { p, err := parsePriority(priorityStr) if err != nil { return &mcp.CallToolResult{IsError: true, Content: []mcp.Content{mcp.TextContent{Type: "text", Text: fmt.Sprintf("Invalid priority: %v", err)}}}, nil } priority = &p } // Resolve team identifier to a team ID var teamID string team := request.GetString("team", "") if team != "" { // Resolve team identifier to a team ID teamID, err = resolveTeamIdentifier(linearClient, team) if err != nil { return &mcp.CallToolResult{IsError: true, Content: []mcp.Content{mcp.TextContent{Type: "text", Text: fmt.Sprintf("Failed to resolve team: %v", err)}}}, nil } } status := request.GetString("status", "") projectID := request.GetString("projectId", "") milestoneID := request.GetString("milestoneId", "") // Update the issue input := linear.UpdateIssueInput{ ID: id, Title: title, Description: description, Priority: priority, Status: status, TeamID: teamID, ProjectID: projectID, MilestoneID: milestoneID, } issue, err := linearClient.UpdateIssue(input) if err != nil { return &mcp.CallToolResult{IsError: true, Content: []mcp.Content{mcp.TextContent{Type: "text", Text: fmt.Sprintf("Failed to update issue: %v", err)}}}, nil } // Return the result resultText := fmt.Sprintf("Updated %s", formatIssueIdentifier(issue)) resultText += fmt.Sprintf("\nURL: %s", issue.URL) return &mcp.CallToolResult{Content: []mcp.Content{mcp.TextContent{Type: "text", Text: resultText}}}, nil } } ``` -------------------------------------------------------------------------------- /testdata/fixtures/get_milestone_handler_Non-existent milestone.yaml: -------------------------------------------------------------------------------- ```yaml --- version: 2 interactions: - id: 0 request: proto: HTTP/1.1 proto_major: 1 proto_minor: 1 content_length: 282 transfer_encoding: [] trailer: {} host: api.linear.app remote_addr: "" request_uri: "" 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":"non-existent-milestone"}}' form: {} headers: Content-Type: - application/json url: https://api.linear.app/graphql method: POST response: proto: HTTP/2.0 proto_major: 2 proto_minor: 0 transfer_encoding: [] trailer: {} content_length: -1 uncompressed: true body: | {"errors":[{"message":"Entity not found: ProjectMilestone","path":["projectMilestone"],"locations":[{"line":3,"column":4}],"extensions":{"type":"invalid input","code":"INPUT_ERROR","statusCode":400,"userError":true,"userPresentableMessage":"Could not find referenced ProjectMilestone."}}],"data":null} headers: Alt-Svc: - h3=":443"; ma=86400 Cache-Control: - no-store Cf-Cache-Status: - DYNAMIC Content-Type: - application/json; charset=utf-8 Etag: - W/"12e-0KhOKt5R3marKFxUi49Nm51rMnE" Server: - cloudflare Vary: - Accept-Encoding Via: - 1.1 google status: 200 OK code: 200 duration: 0s - id: 1 request: proto: HTTP/1.1 proto_major: 1 proto_minor: 1 content_length: 386 transfer_encoding: [] trailer: {} host: api.linear.app remote_addr: "" request_uri: "" body: '{"query":"\n\t\tquery GetMilestoneByName($filter: ProjectMilestoneFilter) {\n\t\t\tprojectMilestones(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\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":{"filter":{"name":{"eq":"non-existent-milestone"}}}}' form: {} headers: Content-Type: - application/json url: https://api.linear.app/graphql method: POST response: proto: HTTP/2.0 proto_major: 2 proto_minor: 0 transfer_encoding: [] trailer: {} content_length: 44 uncompressed: false body: | {"data":{"projectMilestones":{"nodes":[]}}} headers: Alt-Svc: - h3=":443"; ma=86400 Cache-Control: - no-store Cf-Cache-Status: - DYNAMIC Content-Length: - "44" Content-Type: - application/json; charset=utf-8 Etag: - W/"2c-CfwOLPDRtN/upofCH3tMCaygMOE" Server: - cloudflare Vary: - Accept-Encoding Via: - 1.1 google status: 200 OK code: 200 duration: 0s ``` -------------------------------------------------------------------------------- /testdata/fixtures/update_milestone_handler_Non-existent milestone.yaml: -------------------------------------------------------------------------------- ```yaml --- version: 2 interactions: - id: 0 request: proto: HTTP/1.1 proto_major: 1 proto_minor: 1 content_length: 282 transfer_encoding: [] trailer: {} host: api.linear.app remote_addr: "" request_uri: "" 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":"non-existent-milestone"}}' form: {} headers: Content-Type: - application/json url: https://api.linear.app/graphql method: POST response: proto: HTTP/2.0 proto_major: 2 proto_minor: 0 transfer_encoding: [] trailer: {} content_length: -1 uncompressed: true body: | {"errors":[{"message":"Entity not found: ProjectMilestone","path":["projectMilestone"],"locations":[{"line":3,"column":4}],"extensions":{"type":"invalid input","code":"INPUT_ERROR","statusCode":400,"userError":true,"userPresentableMessage":"Could not find referenced ProjectMilestone."}}],"data":null} headers: Alt-Svc: - h3=":443"; ma=86400 Cache-Control: - no-store Cf-Cache-Status: - DYNAMIC Content-Type: - application/json; charset=utf-8 Etag: - W/"12e-0KhOKt5R3marKFxUi49Nm51rMnE" Server: - cloudflare Vary: - Accept-Encoding Via: - 1.1 google status: 200 OK code: 200 duration: 0s - id: 1 request: proto: HTTP/1.1 proto_major: 1 proto_minor: 1 content_length: 386 transfer_encoding: [] trailer: {} host: api.linear.app remote_addr: "" request_uri: "" body: '{"query":"\n\t\tquery GetMilestoneByName($filter: ProjectMilestoneFilter) {\n\t\t\tprojectMilestones(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\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":{"filter":{"name":{"eq":"non-existent-milestone"}}}}' form: {} headers: Content-Type: - application/json url: https://api.linear.app/graphql method: POST response: proto: HTTP/2.0 proto_major: 2 proto_minor: 0 transfer_encoding: [] trailer: {} content_length: 44 uncompressed: false body: | {"data":{"projectMilestones":{"nodes":[]}}} headers: Alt-Svc: - h3=":443"; ma=86400 Cache-Control: - no-store Cf-Cache-Status: - DYNAMIC Content-Length: - "44" Content-Type: - application/json; charset=utf-8 Etag: - W/"2c-CfwOLPDRtN/upofCH3tMCaygMOE" Server: - cloudflare Vary: - Accept-Encoding Via: - 1.1 google status: 200 OK code: 200 duration: 0s ``` -------------------------------------------------------------------------------- /testdata/fixtures/update_initiative_handler_Valid update.yaml: -------------------------------------------------------------------------------- ```yaml --- version: 2 interactions: - id: 0 request: proto: HTTP/1.1 proto_major: 1 proto_minor: 1 content_length: 220 transfer_encoding: [] trailer: {} host: api.linear.app remote_addr: "" request_uri: "" 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":"c6a7dd0c-cbe2-4101-906d-ddd97acb2241"}}' form: {} headers: Content-Type: - application/json url: https://api.linear.app/graphql method: POST response: proto: HTTP/2.0 proto_major: 2 proto_minor: 0 transfer_encoding: [] trailer: {} content_length: -1 uncompressed: true body: | {"data":{"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"}}} headers: Alt-Svc: - h3=":443"; ma=86400 Cache-Control: - no-store Cf-Cache-Status: - DYNAMIC Content-Type: - application/json; charset=utf-8 Etag: - W/"ea-uo1CNA8fgECA7BrV1BpztW43pw4" Server: - cloudflare Vary: - Accept-Encoding Via: - 1.1 google status: 200 OK code: 200 duration: 0s - id: 1 request: proto: HTTP/1.1 proto_major: 1 proto_minor: 1 content_length: 416 transfer_encoding: [] trailer: {} host: api.linear.app remote_addr: "" request_uri: "" body: '{"query":"\n\t\tmutation InitiativeUpdate($id: String!, $input: InitiativeUpdateInput!) {\n\t\t\tinitiativeUpdate(id: $id, 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":{"id":"c6a7dd0c-cbe2-4101-906d-ddd97acb2241","input":{"name":"Updated Initiative Name","description":"Updated Description"}}}' form: {} headers: Content-Type: - application/json url: https://api.linear.app/graphql method: POST response: proto: HTTP/2.0 proto_major: 2 proto_minor: 0 transfer_encoding: [] trailer: {} content_length: -1 uncompressed: true body: | {"data":{"initiativeUpdate":{"success":true,"initiative":{"id":"c6a7dd0c-cbe2-4101-906d-ddd97acb2241","name":"Updated Initiative Name","description":"Updated Description","url":"https://linear.app/linear-mcp-go-test/initiative/updated-initiative-name-e209008074dc"}}}} headers: Alt-Svc: - h3=":443"; ma=86400 Cache-Control: - no-store Cf-Cache-Status: - DYNAMIC Content-Type: - application/json; charset=utf-8 Etag: - W/"10d-JARpP5QUsJRs1w/2NBEsL4A+GRY" Server: - cloudflare Vary: - Accept-Encoding Via: - 1.1 google status: 200 OK code: 200 duration: 0s ``` -------------------------------------------------------------------------------- /testdata/fixtures/get_milestone_handler_By name.yaml: -------------------------------------------------------------------------------- ```yaml --- version: 2 interactions: - id: 0 request: proto: HTTP/1.1 proto_major: 1 proto_minor: 1 content_length: 276 transfer_encoding: [] trailer: {} host: api.linear.app remote_addr: "" request_uri: "" 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":"Test Milestone 2"}}' form: {} headers: Content-Type: - application/json url: https://api.linear.app/graphql method: POST response: proto: HTTP/2.0 proto_major: 2 proto_minor: 0 transfer_encoding: [] trailer: {} content_length: -1 uncompressed: true body: | {"errors":[{"message":"Entity not found: ProjectMilestone","path":["projectMilestone"],"locations":[{"line":3,"column":4}],"extensions":{"type":"invalid input","code":"INPUT_ERROR","statusCode":400,"userError":true,"userPresentableMessage":"Could not find referenced ProjectMilestone."}}],"data":null} headers: Alt-Svc: - h3=":443"; ma=86400 Cache-Control: - no-store Cf-Cache-Status: - DYNAMIC Content-Type: - application/json; charset=utf-8 Etag: - W/"12e-0KhOKt5R3marKFxUi49Nm51rMnE" Server: - cloudflare Vary: - Accept-Encoding Via: - 1.1 google status: 200 OK code: 200 duration: 0s - id: 1 request: proto: HTTP/1.1 proto_major: 1 proto_minor: 1 content_length: 380 transfer_encoding: [] trailer: {} host: api.linear.app remote_addr: "" request_uri: "" body: '{"query":"\n\t\tquery GetMilestoneByName($filter: ProjectMilestoneFilter) {\n\t\t\tprojectMilestones(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\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":{"filter":{"name":{"eq":"Test Milestone 2"}}}}' form: {} headers: Content-Type: - application/json url: https://api.linear.app/graphql method: POST response: proto: HTTP/2.0 proto_major: 2 proto_minor: 0 transfer_encoding: [] trailer: {} content_length: -1 uncompressed: true body: | {"data":{"projectMilestones":{"nodes":[{"id":"67ae1d0a-107b-42af-b063-c31a3fa05fd1","name":"Test Milestone 2","description":"Test Description","targetDate":"2024-12-31","project":{"id":"bfa49864-16c9-44db-994e-a11ba2b386f1","name":"Updated Project Name 2"}}]}}} headers: Alt-Svc: - h3=":443"; ma=86400 Cache-Control: - no-store Cf-Cache-Status: - DYNAMIC Content-Type: - application/json; charset=utf-8 Etag: - W/"106-9HbnUOf8b5eDeaQSxg5wvPdcXPI" Server: - cloudflare Vary: - Accept-Encoding Via: - 1.1 google status: 200 OK code: 200 duration: 0s ``` -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- ```yaml name: Build and Release on: push: branches: [ main ] tags: [ 'v*' ] pull_request: branches: [ main ] jobs: build-and-test: name: Build and Test runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v4 - name: Set up Go uses: actions/setup-go@v5 with: go-version: '1.23.x' check-latest: true - name: Build run: go build -ldflags "-X github.com/geropl/linear-mcp-go/cmd.GitCommit=${{ github.sha }} -X github.com/geropl/linear-mcp-go/cmd.BuildDate=$(date -u +%Y-%m-%dT%H:%M:%SZ)" -v ./... - name: Test run: go test -v ./... create-release: name: Create Release needs: build-and-test if: startsWith(github.ref, 'refs/tags/v') runs-on: ubuntu-latest permissions: contents: write # This gives permission to create releases and upload assets steps: - name: Checkout code uses: actions/checkout@v4 - name: Set up Go uses: actions/setup-go@v5 with: go-version: '1.23.x' check-latest: true - name: Get version from tag id: get_version run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT - name: Build for Linux run: | GOOS=linux GOARCH=amd64 go build -ldflags "-X github.com/geropl/linear-mcp-go/cmd.GitCommit=${{ github.sha }} -X github.com/geropl/linear-mcp-go/cmd.BuildDate=$(date -u +%Y-%m-%dT%H:%M:%SZ)" -o linear-mcp-go-linux-amd64 -v . chmod +x linear-mcp-go-linux-amd64 - name: Build for macOS run: | GOOS=darwin GOARCH=amd64 go build -ldflags "-X github.com/geropl/linear-mcp-go/cmd.GitCommit=${{ github.sha }} -X github.com/geropl/linear-mcp-go/cmd.BuildDate=$(date -u +%Y-%m-%dT%H:%M:%SZ)" -o linear-mcp-go-darwin-amd64 -v . chmod +x linear-mcp-go-darwin-amd64 - name: Build for Windows run: | GOOS=windows GOARCH=amd64 go build -ldflags "-X github.com/geropl/linear-mcp-go/cmd.GitCommit=${{ github.sha }} -X github.com/geropl/linear-mcp-go/cmd.BuildDate=$(date -u +%Y-%m-%dT%H:%M:%SZ)" -o linear-mcp-go-windows-amd64.exe -v . - name: Create Release id: create_release uses: actions/create-release@v1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: tag_name: ${{ github.ref }} release_name: Linear MCP Server ${{ steps.get_version.outputs.VERSION }} draft: false prerelease: false - name: Upload Linux Binary uses: actions/upload-release-asset@v1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: upload_url: ${{ steps.create_release.outputs.upload_url }} asset_path: ./linear-mcp-go-linux-amd64 asset_name: linear-mcp-go-linux-amd64 asset_content_type: application/octet-stream - name: Upload macOS Binary uses: actions/upload-release-asset@v1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: upload_url: ${{ steps.create_release.outputs.upload_url }} asset_path: ./linear-mcp-go-darwin-amd64 asset_name: linear-mcp-go-darwin-amd64 asset_content_type: application/octet-stream - name: Upload Windows Binary uses: actions/upload-release-asset@v1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: upload_url: ${{ steps.create_release.outputs.upload_url }} asset_path: ./linear-mcp-go-windows-amd64.exe asset_name: linear-mcp-go-windows-amd64.exe asset_content_type: application/octet-stream ``` -------------------------------------------------------------------------------- /testdata/fixtures/search_issues_handler_Search by team.yaml: -------------------------------------------------------------------------------- ```yaml --- version: 2 interactions: - id: 0 request: proto: HTTP/1.1 proto_major: 1 proto_minor: 1 content_length: 703 transfer_encoding: [] trailer: {} host: api.linear.app remote_addr: "" request_uri: "" 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":{"team":{"id":{"eq":"234c5451-a839-4c8f-98d9-da00973f1060"}}},"first":5,"includeArchived":false}}' form: {} headers: Content-Type: - application/json url: https://api.linear.app/graphql method: POST response: proto: HTTP/2.0 proto_major: 2 proto_minor: 0 transfer_encoding: [] trailer: {} content_length: -1 uncompressed: true body: | {"data":{"issues":{"nodes":[{"id":"acdeb5e4-bf7e-4281-9a15-ffea27aa5965","identifier":"TEST-71","title":"Sub Issue with Labels","description":null,"priority":0,"url":"https://linear.app/linear-mcp-go-test/issue/TEST-71/sub-issue-with-labels","state":{"id":"42f7ad15-fca3-4d11-b349-0e3c1385c256","name":"Backlog"},"assignee":null,"labels":{"nodes":[{"id":"fcd49e32-5043-4bfd-88a5-2bbe3c95124a","name":"ws-label 2"},{"id":"94087865-ce6c-470b-896c-4d1d2c7456b8","name":"Feature"}]}},{"id":"2486653d-f073-4bdc-a94d-eab0e34587c9","identifier":"TEST-70","title":"Issue with Labels","description":null,"priority":0,"url":"https://linear.app/linear-mcp-go-test/issue/TEST-70/issue-with-labels","state":{"id":"42f7ad15-fca3-4d11-b349-0e3c1385c256","name":"Backlog"},"assignee":null,"labels":{"nodes":[{"id":"37e1cdc8-a696-4412-8ad7-8ba8435ba0f4","name":"team label 1"}]}},{"id":"de74de85-b597-444e-abdd-c75052e72f37","identifier":"TEST-69","title":"Sub Issue","description":null,"priority":0,"url":"https://linear.app/linear-mcp-go-test/issue/TEST-69/sub-issue","state":{"id":"42f7ad15-fca3-4d11-b349-0e3c1385c256","name":"Backlog"},"assignee":null,"labels":{"nodes":[]}},{"id":"88460755-1c87-4a8a-927b-6a729275c9c7","identifier":"TEST-68","title":"Sub Issue","description":null,"priority":0,"url":"https://linear.app/linear-mcp-go-test/issue/TEST-68/sub-issue","state":{"id":"42f7ad15-fca3-4d11-b349-0e3c1385c256","name":"Backlog"},"assignee":null,"labels":{"nodes":[]}},{"id":"8a2c9f4a-88ce-4f02-9b42-81c257d7a4f7","identifier":"TEST-67","title":"Test Issue with team key","description":null,"priority":0,"url":"https://linear.app/linear-mcp-go-test/issue/TEST-67/test-issue-with-team-key","state":{"id":"42f7ad15-fca3-4d11-b349-0e3c1385c256","name":"Backlog"},"assignee":null,"labels":{"nodes":[]}}]}}} headers: Alt-Svc: - h3=":443"; ma=86400 Cache-Control: - no-store Cf-Cache-Status: - DYNAMIC Content-Type: - application/json; charset=utf-8 Etag: - W/"705-mKrlAmxVi/v3PBWaz/5XCTzocI0" Server: - cloudflare Vary: - Accept-Encoding Via: - 1.1 google status: 200 OK code: 200 duration: 0s ``` -------------------------------------------------------------------------------- /testdata/fixtures/update_milestone_handler_Valid update.yaml: -------------------------------------------------------------------------------- ```yaml --- version: 2 interactions: - id: 0 request: proto: HTTP/1.1 proto_major: 1 proto_minor: 1 content_length: 296 transfer_encoding: [] trailer: {} host: api.linear.app remote_addr: "" request_uri: "" 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":"2d95299d-1341-484b-ab00-5cb587f2cc67"}}' form: {} headers: Content-Type: - application/json url: https://api.linear.app/graphql method: POST response: proto: HTTP/2.0 proto_major: 2 proto_minor: 0 transfer_encoding: [] trailer: {} content_length: -1 uncompressed: true body: | {"data":{"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"}}}} headers: Alt-Svc: - h3=":443"; ma=86400 Cache-Control: - no-store Cf-Cache-Status: - DYNAMIC Content-Type: - application/json; charset=utf-8 Etag: - W/"e5-h5yNN+ZdI94WBaZJFpZUdcwi9uA" Server: - cloudflare Vary: - Accept-Encoding Via: - 1.1 google status: 200 OK code: 200 duration: 0s - id: 1 request: proto: HTTP/1.1 proto_major: 1 proto_minor: 1 content_length: 543 transfer_encoding: [] trailer: {} host: api.linear.app remote_addr: "" request_uri: "" body: '{"query":"\n\t\tmutation ProjectMilestoneUpdate($id: String!, $input: ProjectMilestoneUpdateInput!) {\n\t\t\tprojectMilestoneUpdate(id: $id, 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":{"id":"2d95299d-1341-484b-ab00-5cb587f2cc67","input":{"name":"Updated Milestone Name 22","description":"Updated Description","targetDate":"2025-01-01"}}}' form: {} headers: Content-Type: - application/json url: https://api.linear.app/graphql method: POST response: proto: HTTP/2.0 proto_major: 2 proto_minor: 0 transfer_encoding: [] trailer: {} content_length: -1 uncompressed: true body: | {"data":{"projectMilestoneUpdate":{"success":true,"projectMilestone":{"id":"2d95299d-1341-484b-ab00-5cb587f2cc67","name":"Updated Milestone Name 22","description":"Updated Description","targetDate":"2025-01-01","project":{"id":"bfa49864-16c9-44db-994e-a11ba2b386f1","name":"Updated Project Name 2"}}}}} headers: Alt-Svc: - h3=":443"; ma=86400 Cache-Control: - no-store Cf-Cache-Status: - DYNAMIC Content-Type: - application/json; charset=utf-8 Etag: - W/"12f-fyPVeE08hVwySnKgSCGm3ErLoSE" Server: - cloudflare Vary: - Accept-Encoding Via: - 1.1 google status: 200 OK code: 200 duration: 0s ``` -------------------------------------------------------------------------------- /testdata/fixtures/add_comment_handler_Reply_to_comment.yaml: -------------------------------------------------------------------------------- ```yaml --- version: 2 interactions: - id: 0 request: proto: HTTP/1.1 proto_major: 1 proto_minor: 1 content_length: 322 transfer_encoding: [] trailer: {} host: api.linear.app remote_addr: "" request_uri: "" 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"}}' form: {} headers: Content-Type: - application/json url: https://api.linear.app/graphql method: POST response: proto: HTTP/2.0 proto_major: 2 proto_minor: 0 transfer_encoding: [] trailer: {} content_length: -1 uncompressed: true body: | {"data":{"issues":{"nodes":[{"id":"1c2de93f-4321-4015-bfde-ee893ef7976f","identifier":"TEST-10","title":"Updated Test Issue"}]}}} headers: Alt-Svc: - h3=":443"; ma=86400 Cache-Control: - no-store Cf-Cache-Status: - DYNAMIC Content-Type: - application/json; charset=utf-8 Etag: - W/"82-w0K/VnjlqJtYAurPyBwU/9QgAFo" Server: - cloudflare Vary: - Accept-Encoding Via: - 1.1 google status: 200 OK code: 200 duration: 0s - id: 1 request: proto: HTTP/1.1 proto_major: 1 proto_minor: 1 content_length: 575 transfer_encoding: [] trailer: {} host: api.linear.app remote_addr: "" request_uri: "" body: '{"query":"\n\t\tmutation AddComment($input: CommentCreateInput!) {\n\t\t\tcommentCreate(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":{"input":{"body":"This is a reply to the comment","issueId":"1c2de93f-4321-4015-bfde-ee893ef7976f","parentId":"ae3d62d6-3f40-4990-867b-5c97dd265a40"}}}' form: {} headers: Content-Type: - application/json url: https://api.linear.app/graphql method: POST response: proto: HTTP/2.0 proto_major: 2 proto_minor: 0 transfer_encoding: [] trailer: {} content_length: -1 uncompressed: true body: | {"data":{"commentCreate":{"success":true,"comment":{"id":"6a2b6ab6-e3b8-41bd-bdd6-6891a8d6d86f","body":"This is a reply to the comment","url":"https://linear.app/linear-mcp-go-test/issue/TEST-10/updated-test-issue#comment-6a2b6ab6","createdAt":"2025-10-07T16:13:07.481Z","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"}}}}} headers: Alt-Svc: - h3=":443"; ma=86400 Cache-Control: - no-store Cf-Cache-Status: - DYNAMIC Content-Type: - application/json; charset=utf-8 Etag: - W/"219-613DOqbo34flC1F2tWNw/F01f4k" Server: - cloudflare Vary: - Accept-Encoding Via: - 1.1 google status: 200 OK code: 200 duration: 0s ```