This is page 2 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 -------------------------------------------------------------------------------- /.clinerules/memory-bank.md: -------------------------------------------------------------------------------- ```markdown # Cline's Memory Bank I am Cline, an expert software engineer with a unique characteristic: my memory resets completely between sessions. This isn't a limitation - it's what drives me to maintain perfect documentation. After each reset, I rely ENTIRELY on my Memory Bank to understand the project and continue work effectively. I MUST read ALL memory bank files at the start of EVERY task - this is not optional. ## Memory Bank Structure The Memory Bank consists of core files and optional context files, all in Markdown format. Files build upon each other in a clear hierarchy: flowchart TD PB[projectbrief.md] --> PC[productContext.md] PB --> SP[systemPatterns.md] PB --> TC[techContext.md] PC --> AC[activeContext.md] SP --> AC TC --> AC TC --> DW[developmentWorkflows.md] SP --> DW AC --> P[progress.md] ### Core Files (Required) 1. `projectbrief.md` - Foundation document that shapes all other files - Created at project start if it doesn't exist - Defines core requirements and goals - Source of truth for project scope 2. `productContext.md` - Why this project exists - Problems it solves - How it should work - User experience goals 3. `activeContext.md` - Current work focus - Recent changes - Next steps - Active decisions and considerations - Important patterns and preferences - Learnings and project insights 4. `systemPatterns.md` - System architecture - Key technical decisions - Design patterns in use - Component relationships - Critical implementation paths 5. `techContext.md` - Technologies used - Development setup - Technical constraints - Dependencies - Tool usage patterns 6. `progress.md` - What works - What's left to build - Current status - Known issues - Evolution of project decisions ### Development-Specific Files (Critical for Development Tasks) 7. `developmentWorkflows.md` - Git workflow and branch management - Release process and versioning - Build, test, and deployment commands - Code quality standards and practices - Debugging and troubleshooting guides - **Essential for any development work** ### Additional Context Create additional files/folders within memory-bank/ when they help organize: - Complex feature documentation - Integration specifications - API documentation - Testing strategies - Deployment procedures ## Core Workflows ### Plan Mode flowchart TD Start[Start] --> ReadFiles[Read Memory Bank] ReadFiles --> CheckFiles{Files Complete?} CheckFiles -->|No| Plan[Create Plan] Plan --> Document[Document in Chat] CheckFiles -->|Yes| Verify[Verify Context] Verify --> Strategy[Develop Strategy] Strategy --> Present[Present Approach] ### Act Mode flowchart TD Start[Start] --> Context[Check Memory Bank] Context --> Update[Update Documentation] Update --> Execute[Execute Task] Execute --> Document[Document Changes] ## Documentation Updates Memory Bank updates occur when: 1. Discovering new project patterns 2. After implementing significant changes 3. When user requests with **update memory bank** (MUST review ALL files) 4. When context needs clarification 5. When development processes change (update developmentWorkflows.md) flowchart TD Start[Update Process] subgraph Process P1[Review ALL Files] P2[Document Current State] P3[Clarify Next Steps] P4[Document Insights & Patterns] P1 --> P2 --> P3 --> P4 end Start --> Process Note: When triggered by **update memory bank**, I MUST review every memory bank file, even if some don't require updates. Focus particularly on activeContext.md and progress.md as they track current state. REMEMBER: After every memory reset, I begin completely fresh. The Memory Bank is my only link to previous work. It must be maintained with precision and clarity, as my effectiveness depends entirely on its accuracy. ``` -------------------------------------------------------------------------------- /testdata/fixtures/add_comment_handler_Valid 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 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":{"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: 507 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":"Test comment","issueId":"1c2de93f-4321-4015-bfde-ee893ef7976f"}}}' 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":{"commentCreate":{"success":true,"comment":{"id":"ae3d62d6-3f40-4990-867b-5c97dd265a40","body":"Test comment","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/"207-A36zaBZM2etAKuEB572ckko3PUw" Server: - cloudflare Vary: - Accept-Encoding Via: - 1.1 google status: 200 OK code: 200 duration: 0s ``` -------------------------------------------------------------------------------- /testdata/fixtures/update_comment_handler_Valid comment update with shorthand.yaml: -------------------------------------------------------------------------------- ```yaml --- version: 2 interactions: - id: 0 request: proto: HTTP/1.1 proto_major: 1 proto_minor: 1 content_length: 255 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":"ae3d62d6"}}' 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":{"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"}}}} headers: Alt-Svc: - h3=":443"; ma=86400 Cache-Control: - no-store Cf-Cache-Status: - DYNAMIC Content-Type: - application/json; charset=utf-8 Etag: - W/"135-SJj8cT5t4cfk3yiLplqz0T3j/uE" 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: 550 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 via shorthand"}}}' 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 via shorthand","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/"21d-cSmAgkHmAGU1qbZ3TOEPbZCgEx8" Server: - cloudflare Vary: - Accept-Encoding Via: - 1.1 google status: 200 OK code: 200 duration: 0s ``` -------------------------------------------------------------------------------- /testdata/fixtures/update_issue_handler_Valid update.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 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":{"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: 589 transfer_encoding: [] trailer: {} host: api.linear.app remote_addr: "" request_uri: "" body: '{"query":"\n\t\tmutation UpdateIssue($id: String!, $input: IssueUpdateInput!) {\n\t\t\tissueUpdate(id: $id, 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":{"id":"1c2de93f-4321-4015-bfde-ee893ef7976f","input":{"title":"Updated 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":{"issueUpdate":{"success":true,"issue":{"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","createdAt":"2025-03-03T11:34:49.241Z","updatedAt":"2025-03-30T10:09:33.866Z","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 Content-Type: - application/json; charset=utf-8 Etag: - W/"1ed-NQYcphbrRBp9Qc2UkRq4wmgK4Us" Server: - cloudflare Vary: - Accept-Encoding Via: - 1.1 google status: 200 OK code: 200 duration: 0s ``` -------------------------------------------------------------------------------- /testdata/fixtures/update_comment_handler_Valid comment update with hash only.yaml: -------------------------------------------------------------------------------- ```yaml --- version: 2 interactions: - id: 0 request: proto: HTTP/1.1 proto_major: 1 proto_minor: 1 content_length: 255 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":"ae3d62d6"}}' 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":{"comment":{"id":"ae3d62d6-3f40-4990-867b-5c97dd265a40","body":"Updated comment text via shorthand","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"}}}} headers: Alt-Svc: - h3=":443"; ma=86400 Cache-Control: - no-store Cf-Cache-Status: - DYNAMIC Content-Type: - application/json; charset=utf-8 Etag: - W/"143-m1nvHd82UihGhurKSTyMAF0whhw" 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: 545 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 via hash"}}}' 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 via hash","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/"218-JZAC2Y8E9AKSYWGafAFwxnfBleE" Server: - cloudflare Vary: - Accept-Encoding Via: - 1.1 google status: 200 OK code: 200 duration: 0s ``` -------------------------------------------------------------------------------- /testdata/fixtures/resource_TeamResourceHandler_Fetch By Key.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 - id: 1 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 Name.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 - id: 1 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 ``` -------------------------------------------------------------------------------- /pkg/tools/get_issue_comments.go: -------------------------------------------------------------------------------- ```go package tools import ( "context" "fmt" "github.com/geropl/linear-mcp-go/pkg/linear" "github.com/mark3labs/mcp-go/mcp" ) // GetIssueCommentsTool is the tool definition for getting paginated comments for an issue var GetIssueCommentsTool = mcp.NewTool("linear_get_issue_comments", mcp.WithDescription("Retrieves a flat list of comments for a Linear issue and thread. Use to list all replies in a thread or all top-level comments."), mcp.WithString("issue", mcp.Required(), mcp.Description("issue identifier (e.g., 'TEAM-123')")), mcp.WithString("thread", mcp.Description("Optional thread identifier. Accepts: full URL, UUID, shorthand (comment-abc123), or hash (abc123). If not provided, all top-level comments are returned.")), mcp.WithNumber("limit", mcp.Description("Maximum number of comments to return (default: 10)")), mcp.WithString("after", mcp.Description("Cursor for pagination, to get comments after this point")), ) // GetIssueCommentsHandler handles the linear_get_issue_comments tool func GetIssueCommentsHandler(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 } // Extract optional arguments parentID := request.GetString("thread", "") limit := request.GetInt("limit", 10) afterCursor := request.GetString("after", "") // 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 } // Get the issue for basic information issue, err := linearClient.GetIssue(issueID) if err != nil { return &mcp.CallToolResult{IsError: true, Content: []mcp.Content{mcp.TextContent{Type: "text", Text: fmt.Sprintf("Failed to get issue: %v", err)}}}, nil } // Get the comments commentsInput := linear.GetIssueCommentsInput{ IssueID: issueID, ParentID: parentID, Limit: limit, AfterCursor: afterCursor, } comments, err := linearClient.GetIssueComments(commentsInput) if err != nil { return &mcp.CallToolResult{IsError: true, Content: []mcp.Content{mcp.TextContent{Type: "text", Text: fmt.Sprintf("Failed to get comments: %v", err)}}}, nil } // Format the result var resultText string // Add issue information resultText += formatIssueIdentifier(issue) + "\n" // Add thread information if parentID == "" { resultText += "Thread: none (top-level comments)\n" } else { resultText += fmt.Sprintf("Thread: %s (replies to comment)\n", parentID) } resultText += "\n" // Add comments if len(comments.Nodes) > 0 { resultText += "Comments:\n" for _, comment := range comments.Nodes { createdAt := comment.CreatedAt.Format("2006-01-02 15:04:05") hasReplies := false if comment.Children != nil && len(comment.Children.Nodes) > 0 { hasReplies = true } resultText += fmt.Sprintf("- Comment: %s\n %s\n CreatedAt: %s\n HasReplies: %s\n Body: %s\n", comment.ID, formatUserIdentifier(comment.User), createdAt, formatBool(hasReplies), comment.Body) } } else { resultText += "Comments: None\n" } // Add pagination information resultText += "\nPagination:\n" resultText += fmt.Sprintf("Has more comments: %s\n", formatBool(comments.PageInfo.HasNextPage)) if comments.PageInfo.HasNextPage { resultText += fmt.Sprintf("Next cursor: %s\n", comments.PageInfo.EndCursor) } return &mcp.CallToolResult{Content: []mcp.Content{mcp.TextContent{Type: "text", Text: resultText}}}, nil } } // formatBool formats a boolean value as "yes" or "no" func formatBool(value bool) string { if value { return "yes" } return "no" } ``` -------------------------------------------------------------------------------- /pkg/tools/get_issue.go: -------------------------------------------------------------------------------- ```go package tools import ( "context" "fmt" "github.com/geropl/linear-mcp-go/pkg/linear" "github.com/mark3labs/mcp-go/mcp" ) // GetIssueTool is the tool definition for getting an issue var GetIssueTool = mcp.NewTool("linear_get_issue", mcp.WithDescription("Retrieves a single Linear issue."), mcp.WithString("issue", mcp.Required(), mcp.Description("ID or identifier (e.g., 'TEAM-123') of the issue to retrieve")), ) // GetIssueHandler handles the linear_get_issue tool func GetIssueHandler(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 } // Get the issue issue, err := linearClient.GetIssue(issueID) if err != nil { return &mcp.CallToolResult{IsError: true, Content: []mcp.Content{mcp.TextContent{Type: "text", Text: fmt.Sprintf("Failed to get issue: %v", err)}}}, nil } // Format the result using the full issue formatting resultText := formatIssue(issue) // Add assignee and team information using identifier formatting if issue.Assignee != nil { resultText += fmt.Sprintf("Assignee: %s\n", formatUserIdentifier(issue.Assignee)) } else { resultText += "Assignee: None\n" } resultText += fmt.Sprintf("%s\n", formatTeamIdentifier(issue.Team)) if issue.Project != nil { resultText += fmt.Sprintf("Project: %s (%s)\n", issue.Project.Name, issue.Project.ID) } else { resultText += "Project: None\n" } if issue.ProjectMilestone != nil { resultText += fmt.Sprintf("Milestone: %s (%s)\n", issue.ProjectMilestone.Name, issue.ProjectMilestone.ID) } else { resultText += "Milestone: None\n" } // Add attachments section if there are attachments if issue.Attachments != nil && len(issue.Attachments.Nodes) > 0 { resultText += "\nAttachments:\n" // Display all attachments in a simple list without grouping by source type for _, attachment := range issue.Attachments.Nodes { resultText += fmt.Sprintf("- %s: %s\n", attachment.Title, attachment.URL) if attachment.Subtitle != "" { resultText += fmt.Sprintf(" %s\n", attachment.Subtitle) } } } else { resultText += "\nAttachments: None\n" } // Add related issues section if (issue.Relations != nil && len(issue.Relations.Nodes) > 0) || (issue.InverseRelations != nil && len(issue.InverseRelations.Nodes) > 0) { resultText += "\nRelated Issues:\n" // Add direct relations if issue.Relations != nil && len(issue.Relations.Nodes) > 0 { for _, relation := range issue.Relations.Nodes { if relation.RelatedIssue != nil { resultText += fmt.Sprintf("- %s\n Title: %s\n RelationType: %s\n URL: %s\n", formatIssueIdentifier(relation.RelatedIssue), relation.RelatedIssue.Title, relation.Type, relation.RelatedIssue.URL) } } } // Add inverse relations if issue.InverseRelations != nil && len(issue.InverseRelations.Nodes) > 0 { for _, relation := range issue.InverseRelations.Nodes { if relation.Issue != nil { resultText += fmt.Sprintf("- %s\n Title: %s\n RelationType: %s (inverse)\n URL: %s\n", formatIssueIdentifier(relation.Issue), relation.Issue.Title, relation.Type, relation.Issue.URL) } } } } else { resultText += "\nRelated Issues: None\n" } // Note about comments resultText += "\nComments: Use the linear_get_issue_comments tool to retrieve comments for this issue.\n" return &mcp.CallToolResult{Content: []mcp.Content{mcp.TextContent{Type: "text", Text: resultText}}}, nil } } ``` -------------------------------------------------------------------------------- /testdata/fixtures/reply_to_comment_handler_Valid reply.yaml: -------------------------------------------------------------------------------- ```yaml --- version: 2 interactions: - id: 0 request: proto: HTTP/1.1 proto_major: 1 proto_minor: 1 content_length: 333 transfer_encoding: [] trailer: {} host: api.linear.app remote_addr: "" request_uri: "" body: '{"query":"\n\t\tquery GetComment($id: String!) {\n\t\t\tcomment(id: $id) {\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\tissue {\n\t\t\t\t\tid\n\t\t\t\t\tidentifier\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t","variables":{"id":"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":{"comment":{"id":"ae3d62d6-3f40-4990-867b-5c97dd265a40","body":"Updated comment text via hash","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"}}}} headers: Alt-Svc: - h3=":443"; ma=86400 Cache-Control: - no-store Cf-Cache-Status: - DYNAMIC Content-Type: - application/json; charset=utf-8 Etag: - W/"18b-JtoAyZ6gspXHMOEbvedWimpK4Y0" 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: 585 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 using the dedicated tool","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":"243e8a79-e8cc-4617-848a-573758dcdfd5","body":"This is a reply using the dedicated tool","url":"https://linear.app/linear-mcp-go-test/issue/TEST-10/updated-test-issue#comment-243e8a79","createdAt":"2025-10-07T13:55:14.349Z","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/"223-qLAwFJpu6uPxkTsxKQkgHklk+m4" Server: - cloudflare Vary: - Accept-Encoding Via: - 1.1 google status: 200 OK code: 200 duration: 0s ``` -------------------------------------------------------------------------------- /testdata/fixtures/create_issue_handler_Create sub issue from identifier.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: 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":"3671ad80-a3c1-4783-b5cd-a0fa24e4ff9e","identifier":"TEST-90","title":"Sub Issue","description":null,"priority":0,"url":"https://linear.app/linear-mcp-go-test/issue/TEST-90/sub-issue","createdAt":"2025-10-06T09:44:32.400Z","updatedAt":"2025-10-06T09:44:32.400Z","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-0Svxf7NuXjSFfPU18wFj97GbalA" Server: - cloudflare Vary: - Accept-Encoding Via: - 1.1 google status: 200 OK code: 200 duration: 0s ``` -------------------------------------------------------------------------------- /testdata/fixtures/create_issue_handler_Create issue with labels.yaml: -------------------------------------------------------------------------------- ```yaml --- version: 2 interactions: - id: 0 request: proto: HTTP/1.1 proto_major: 1 proto_minor: 1 content_length: 342 transfer_encoding: [] trailer: {} host: api.linear.app remote_addr: "" request_uri: "" body: '{"query":"\n\t\tquery GetLabelsByName($teamId: String!, $names: [String!]!) {\n\t\t\tteam(id: $teamId) {\n\t\t\t\tlabels(filter: { name: { in: $names } }) {\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}\n\t\t}\n\t","variables":{"names":["team label 1"],"teamId":"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":{"team":{"labels":{"nodes":[{"id":"37e1cdc8-a696-4412-8ad7-8ba8435ba0f4","name":"team label 1"}]}}}} headers: Alt-Svc: - h3=":443"; ma=86400 Cache-Control: - no-store Cf-Cache-Status: - DYNAMIC Content-Type: - application/json; charset=utf-8 Etag: - W/"6d-NtoFPWMa8e+cqurX/zzYmekk2Dg" 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: 890 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":"","labelIds":["37e1cdc8-a696-4412-8ad7-8ba8435ba0f4"],"teamId":"234c5451-a839-4c8f-98d9-da00973f1060","title":"Issue with Labels"}}}' 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":"640b14f2-643e-4010-a194-c4f1c2f6177b","identifier":"TEST-91","title":"Issue with Labels","description":null,"priority":0,"url":"https://linear.app/linear-mcp-go-test/issue/TEST-91/issue-with-labels","createdAt":"2025-10-06T09:44:37.052Z","updatedAt":"2025-10-06T09:44:37.052Z","state":{"id":"42f7ad15-fca3-4d11-b349-0e3c1385c256","name":"Backlog"},"team":{"id":"234c5451-a839-4c8f-98d9-da00973f1060","name":"Test Team","key":"TEST"},"labels":{"nodes":[{"id":"37e1cdc8-a696-4412-8ad7-8ba8435ba0f4","name":"team label 1"}]},"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/"26b-Kh3ayVGqSTqbO3LB/W4HMT1nF4s" Server: - cloudflare Vary: - Accept-Encoding Via: - 1.1 google status: 200 OK code: 200 duration: 0s ``` -------------------------------------------------------------------------------- /testdata/fixtures/update_project_handler_Valid update.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":"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":{"project":{"id":"bfa49864-16c9-44db-994e-a11ba2b386f1","name":"Updated Project Name 2","description":"Updated Description Only","slugId":"e1153169a428","state":"backlog","url":"https://linear.app/linear-mcp-go-test/project/updated-project-name-2-e1153169a428","createdAt":"2025-06-28T18:42:20.223Z","updatedAt":"2025-06-28T18:56:53.580Z","lead":null,"members":{"nodes":[]},"teams":{"nodes":[{"id":"234c5451-a839-4c8f-98d9-da00973f1060","name":"Test Team","key":"TEST"}]},"initiatives":{"nodes":[]},"startDate":null,"targetDate":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/"221-5dHPmIHEC1rBzEVJqPrdCyJ2NbY" 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: 400 transfer_encoding: [] trailer: {} host: api.linear.app remote_addr: "" request_uri: "" body: '{"query":"\n\t\tmutation ProjectUpdate($id: String!, $input: ProjectUpdateInput!) {\n\t\t\tprojectUpdate(id: $id, 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":{"id":"bfa49864-16c9-44db-994e-a11ba2b386f1","input":{"name":"Updated Project 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: | {"data":{"projectUpdate":{"success":true,"project":{"id":"bfa49864-16c9-44db-994e-a11ba2b386f1","name":"Updated Project Name","description":"Updated Description Only","slugId":"e1153169a428","state":"backlog","url":"https://linear.app/linear-mcp-go-test/project/updated-project-name-e1153169a428"}}}} headers: Alt-Svc: - h3=":443"; ma=86400 Cache-Control: - no-store Cf-Cache-Status: - DYNAMIC Content-Type: - application/json; charset=utf-8 Etag: - W/"12d-wnpLXmtIiIJiE+9jOexlH0g7wGY" Server: - cloudflare Vary: - Accept-Encoding Via: - 1.1 google status: 200 OK code: 200 duration: 0s ``` -------------------------------------------------------------------------------- /testdata/fixtures/get_project_handler_Non-existent slug.yaml: -------------------------------------------------------------------------------- ```yaml --- version: 2 interactions: - id: 0 request: proto: HTTP/1.1 proto_major: 1 proto_minor: 1 content_length: 714 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":"non-existent-slug"}}' 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: Project","path":["project"],"locations":[{"line":3,"column":4}],"extensions":{"type":"invalid input","code":"INPUT_ERROR","statusCode":400,"userError":true,"userPresentableMessage":"Could not find referenced Project."}}],"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/"113-pUQ9mkDn3KWYiQz0UBE51+d7gJ4" 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: 906 transfer_encoding: [] trailer: {} host: api.linear.app remote_addr: "" request_uri: "" body: '{"query":"\n\t\tquery GetProjectByNameOrSlug($filter: ProjectFilter) {\n\t\t\tprojects(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\tslugId\n\t\t\t\t\tstate\n\t\t\t\t\turl\n\t\t\t\t\tcreatedAt\n\t\t\t\t\tupdatedAt\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\temail\n\t\t\t\t\t}\n\t\t\t\t\tmembers {\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\temail\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\tteams {\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\tkey\n\t\t\t\t\t\t}\n\t\t\t\t\t}\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\tstartDate\n\t\t\t\t\ttargetDate\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t","variables":{"filter":{"or":[{"name":{"eq":"non-existent-slug"}},{"slugId":{"eq":"slug"}}]}}}' 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/get_project_handler_Invalid project.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 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":"NONEXISTENT-PROJECT"}}' 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: Project","path":["project"],"locations":[{"line":3,"column":4}],"extensions":{"type":"invalid input","code":"INPUT_ERROR","statusCode":400,"userError":true,"userPresentableMessage":"Could not find referenced Project."}}],"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/"113-pUQ9mkDn3KWYiQz0UBE51+d7gJ4" 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: 911 transfer_encoding: [] trailer: {} host: api.linear.app remote_addr: "" request_uri: "" body: '{"query":"\n\t\tquery GetProjectByNameOrSlug($filter: ProjectFilter) {\n\t\t\tprojects(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\tslugId\n\t\t\t\t\tstate\n\t\t\t\t\turl\n\t\t\t\t\tcreatedAt\n\t\t\t\t\tupdatedAt\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\temail\n\t\t\t\t\t}\n\t\t\t\t\tmembers {\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\temail\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\tteams {\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\tkey\n\t\t\t\t\t\t}\n\t\t\t\t\t}\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\tstartDate\n\t\t\t\t\ttargetDate\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t","variables":{"filter":{"or":[{"name":{"eq":"NONEXISTENT-PROJECT"}},{"slugId":{"eq":"PROJECT"}}]}}}' 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/update_project_handler_Update only description.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":"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":{"project":{"id":"bfa49864-16c9-44db-994e-a11ba2b386f1","name":"Updated Project Name 2","description":"Updated Description","slugId":"e1153169a428","state":"backlog","url":"https://linear.app/linear-mcp-go-test/project/updated-project-name-2-e1153169a428","createdAt":"2025-06-28T18:42:20.223Z","updatedAt":"2025-06-28T21:44:56.564Z","lead":null,"members":{"nodes":[]},"teams":{"nodes":[{"id":"234c5451-a839-4c8f-98d9-da00973f1060","name":"Test Team","key":"TEST"}]},"initiatives":{"nodes":[]},"startDate":null,"targetDate":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/"21c-zZT5JHii4UiJPH+GXG9r9OBmr2A" 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: 411 transfer_encoding: [] trailer: {} host: api.linear.app remote_addr: "" request_uri: "" body: '{"query":"\n\t\tmutation ProjectUpdate($id: String!, $input: ProjectUpdateInput!) {\n\t\t\tprojectUpdate(id: $id, 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":{"id":"bfa49864-16c9-44db-994e-a11ba2b386f1","input":{"description":"Updated Description Only"}}}' 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":{"projectUpdate":{"success":true,"project":{"id":"bfa49864-16c9-44db-994e-a11ba2b386f1","name":"Updated Project Name 2","description":"Updated Description Only","slugId":"e1153169a428","state":"backlog","url":"https://linear.app/linear-mcp-go-test/project/updated-project-name-2-e1153169a428"}}}} headers: Alt-Svc: - h3=":443"; ma=86400 Cache-Control: - no-store Cf-Cache-Status: - DYNAMIC Content-Type: - application/json; charset=utf-8 Etag: - W/"131-exvq876nrApQ/gFGvqKIsgVXXo0" Server: - cloudflare Vary: - Accept-Encoding Via: - 1.1 google status: 200 OK code: 200 duration: 0s ``` -------------------------------------------------------------------------------- /testdata/fixtures/create_issue_handler_Create issue with invalid project.yaml: -------------------------------------------------------------------------------- ```yaml --- version: 2 interactions: - id: 0 request: proto: HTTP/1.1 proto_major: 1 proto_minor: 1 content_length: 717 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":"non-existent-project"}}' 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: Project","path":["project"],"locations":[{"line":3,"column":4}],"extensions":{"type":"invalid input","code":"INPUT_ERROR","statusCode":400,"userError":true,"userPresentableMessage":"Could not find referenced Project."}}],"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/"113-pUQ9mkDn3KWYiQz0UBE51+d7gJ4" 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: 912 transfer_encoding: [] trailer: {} host: api.linear.app remote_addr: "" request_uri: "" body: '{"query":"\n\t\tquery GetProjectByNameOrSlug($filter: ProjectFilter) {\n\t\t\tprojects(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\tslugId\n\t\t\t\t\tstate\n\t\t\t\t\turl\n\t\t\t\t\tcreatedAt\n\t\t\t\t\tupdatedAt\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\temail\n\t\t\t\t\t}\n\t\t\t\t\tmembers {\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\temail\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\tteams {\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\tkey\n\t\t\t\t\t\t}\n\t\t\t\t\t}\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\tstartDate\n\t\t\t\t\ttargetDate\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t","variables":{"filter":{"or":[{"name":{"eq":"non-existent-project"}},{"slugId":{"eq":"project"}}]}}}' 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/update_project_handler_Non-existent project.yaml: -------------------------------------------------------------------------------- ```yaml --- version: 2 interactions: - id: 0 request: proto: HTTP/1.1 proto_major: 1 proto_minor: 1 content_length: 717 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":"non-existent-project"}}' 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: Project","path":["project"],"locations":[{"line":3,"column":4}],"extensions":{"type":"invalid input","code":"INPUT_ERROR","statusCode":400,"userError":true,"userPresentableMessage":"Could not find referenced Project."}}],"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/"113-pUQ9mkDn3KWYiQz0UBE51+d7gJ4" 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: 912 transfer_encoding: [] trailer: {} host: api.linear.app remote_addr: "" request_uri: "" body: '{"query":"\n\t\tquery GetProjectByNameOrSlug($filter: ProjectFilter) {\n\t\t\tprojects(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\tslugId\n\t\t\t\t\tstate\n\t\t\t\t\turl\n\t\t\t\t\tcreatedAt\n\t\t\t\t\tupdatedAt\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\temail\n\t\t\t\t\t}\n\t\t\t\t\tmembers {\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\temail\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\tteams {\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\tkey\n\t\t\t\t\t\t}\n\t\t\t\t\t}\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\tstartDate\n\t\t\t\t\ttargetDate\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t","variables":{"filter":{"or":[{"name":{"eq":"non-existent-project"}},{"slugId":{"eq":"project"}}]}}}' 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/update_project_handler_Update name and description.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":"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":{"project":{"id":"bfa49864-16c9-44db-994e-a11ba2b386f1","name":"Updated Project Name","description":"Updated Description Only","slugId":"e1153169a428","state":"backlog","url":"https://linear.app/linear-mcp-go-test/project/updated-project-name-e1153169a428","createdAt":"2025-06-28T18:42:20.223Z","updatedAt":"2025-06-28T21:44:56.396Z","lead":null,"members":{"nodes":[]},"teams":{"nodes":[{"id":"234c5451-a839-4c8f-98d9-da00973f1060","name":"Test Team","key":"TEST"}]},"initiatives":{"nodes":[]},"startDate":null,"targetDate":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/"21d-jg2vG6vT5da0XDzGnJVlIPQka7E" 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: 438 transfer_encoding: [] trailer: {} host: api.linear.app remote_addr: "" request_uri: "" body: '{"query":"\n\t\tmutation ProjectUpdate($id: String!, $input: ProjectUpdateInput!) {\n\t\t\tprojectUpdate(id: $id, 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":{"id":"bfa49864-16c9-44db-994e-a11ba2b386f1","input":{"name":"Updated Project Name 2","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":{"projectUpdate":{"success":true,"project":{"id":"bfa49864-16c9-44db-994e-a11ba2b386f1","name":"Updated Project Name 2","description":"Updated Description","slugId":"e1153169a428","state":"backlog","url":"https://linear.app/linear-mcp-go-test/project/updated-project-name-2-e1153169a428"}}}} headers: Alt-Svc: - h3=":443"; ma=86400 Cache-Control: - no-store Cf-Cache-Status: - DYNAMIC Content-Type: - application/json; charset=utf-8 Etag: - W/"12c-qm99WOFVQtdIlEMxVa4cu3vvOAo" Server: - cloudflare Vary: - Accept-Encoding Via: - 1.1 google status: 200 OK code: 200 duration: 0s ``` -------------------------------------------------------------------------------- /pkg/server/server.go: -------------------------------------------------------------------------------- ```go package server import ( "fmt" "os" "github.com/geropl/linear-mcp-go/pkg/linear" "github.com/geropl/linear-mcp-go/pkg/tools" "github.com/mark3labs/mcp-go/mcp" mcpserver "github.com/mark3labs/mcp-go/server" ) const ( // ServerName is the name of the MCP server ServerName = "Linear MCP Server" // ServerVersion is the version of the MCP server ServerVersion = "1.15.0" ) // LinearMCPServer represents the Linear MCP server type LinearMCPServer struct { mcpServer *mcpserver.MCPServer linearClient *linear.LinearClient writeAccess bool // Controls whether write operations are enabled } // NewLinearMCPServer creates a new Linear MCP server func NewLinearMCPServer(writeAccess bool) (*LinearMCPServer, error) { // Create the Linear client linearClient, err := linear.NewLinearClientFromEnv(ServerVersion) if err != nil { return nil, fmt.Errorf("failed to create Linear client: %w", err) } // Create the MCP server mcpServer := mcpserver.NewMCPServer(ServerName, ServerVersion) // Create the Linear MCP server server := &LinearMCPServer{ mcpServer: mcpServer, linearClient: linearClient, writeAccess: writeAccess, } // Register tools RegisterTools(mcpServer, linearClient, writeAccess) // Register resources RegisterResources(mcpServer, linearClient) return server, nil } // Start starts the Linear MCP server func (s *LinearMCPServer) Start() error { // Check if the Linear API key is set if os.Getenv("LINEAR_API_KEY") == "" { return fmt.Errorf("LINEAR_API_KEY environment variable is required") } // Start the server fmt.Printf("Starting %s v%s\n", ServerName, ServerVersion) return mcpserver.ServeStdio(s.mcpServer) } // GetMCPServer returns the underlying MCP server func (s *LinearMCPServer) GetMCPServer() *mcpserver.MCPServer { return s.mcpServer } // GetLinearClient returns the Linear client func (s *LinearMCPServer) GetLinearClient() *linear.LinearClient { return s.linearClient } // GetReadOnlyToolNames returns the names of all read-only tools func GetReadOnlyToolNames() map[string]bool { return map[string]bool{ "linear_search_issues": true, "linear_get_user_issues": true, "linear_get_issue": true, "linear_get_issue_comments": true, "linear_get_teams": true, "linear_get_project": true, "linear_search_projects": true, "linear_get_milestone": true, "linear_get_initiative": true, } } // RegisterTools registers all Linear tools with the MCP server func RegisterTools(s *mcpserver.MCPServer, linearClient *linear.LinearClient, writeAccess bool) { // Register tools, based on writeAccess addTool := func(tool mcp.Tool, handler mcpserver.ToolHandlerFunc) { if !writeAccess { if readOnly := GetReadOnlyToolNames()[tool.Name]; !readOnly { // Skip registering write tools if write access is disabled return } } s.AddTool(tool, handler) } // Register each tool addTool(tools.SearchIssuesTool, tools.SearchIssuesHandler(linearClient)) addTool(tools.GetUserIssuesTool, tools.GetUserIssuesHandler(linearClient)) addTool(tools.GetIssueTool, tools.GetIssueHandler(linearClient)) addTool(tools.GetIssueCommentsTool, tools.GetIssueCommentsHandler(linearClient)) addTool(tools.GetTeamsTool, tools.GetTeamsHandler(linearClient)) addTool(tools.GetProjectTool, tools.GetProjectHandler(linearClient)) addTool(tools.SearchProjectsTool, tools.SearchProjectsHandler(linearClient)) addTool(tools.CreateProjectTool, tools.CreateProjectHandler(linearClient)) addTool(tools.UpdateProjectTool, tools.UpdateProjectHandler(linearClient)) addTool(tools.GetMilestoneTool, tools.GetMilestoneHandler(linearClient)) addTool(tools.CreateMilestoneTool, tools.CreateMilestoneHandler(linearClient)) addTool(tools.UpdateMilestoneTool, tools.UpdateMilestoneHandler(linearClient)) addTool(tools.GetInitiativeTool, tools.GetInitiativeHandler(linearClient)) addTool(tools.CreateInitiativeTool, tools.CreateInitiativeHandler(linearClient)) addTool(tools.UpdateInitiativeTool, tools.UpdateInitiativeHandler(linearClient)) addTool(tools.CreateIssueTool, tools.CreateIssueHandler(linearClient)) addTool(tools.UpdateIssueTool, tools.UpdateIssueHandler(linearClient)) addTool(tools.AddCommentTool, tools.AddCommentHandler(linearClient)) addTool(tools.ReplyToCommentTool, tools.ReplyToCommentHandler(linearClient)) addTool(tools.UpdateCommentTool, tools.UpdateCommentHandler(linearClient)) } ``` -------------------------------------------------------------------------------- /testdata/fixtures/get_user_issues_handler_Current user issues.yaml: -------------------------------------------------------------------------------- ```yaml --- version: 2 interactions: - id: 0 request: proto: HTTP/1.1 proto_major: 1 proto_minor: 1 content_length: 88 transfer_encoding: [] trailer: {} host: api.linear.app remote_addr: "" request_uri: "" body: '{"query":"\n\t\tquery GetCurrentUser {\n\t\t\tviewer {\n\t\t\t\tid\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":{"viewer":{"id":"cc24eee4-9edc-4bfe-b91b-fedde125ba85"}}} headers: Alt-Svc: - h3=":443"; ma=86400 Cache-Control: - no-store Cf-Cache-Status: - DYNAMIC Content-Type: - application/json; charset=utf-8 Etag: - W/"42-6PvRWCcs8jOXFygoL3FhQP+PK3o" 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: 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/create_issue_handler_Create sub issue with labels.yaml: -------------------------------------------------------------------------------- ```yaml --- version: 2 interactions: - id: 0 request: proto: HTTP/1.1 proto_major: 1 proto_minor: 1 content_length: 350 transfer_encoding: [] trailer: {} host: api.linear.app remote_addr: "" request_uri: "" body: '{"query":"\n\t\tquery GetLabelsByName($teamId: String!, $names: [String!]!) {\n\t\t\tteam(id: $teamId) {\n\t\t\t\tlabels(filter: { name: { in: $names } }) {\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}\n\t\t}\n\t","variables":{"names":["ws-label 2","Feature"],"teamId":"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":{"team":{"labels":{"nodes":[{"id":"fcd49e32-5043-4bfd-88a5-2bbe3c95124a","name":"ws-label 2"},{"id":"94087865-ce6c-470b-896c-4d1d2c7456b8","name":"Feature"}]}}}} headers: Alt-Svc: - h3=":443"; ma=86400 Cache-Control: - no-store Cf-Cache-Status: - DYNAMIC Content-Type: - application/json; charset=utf-8 Etag: - W/"aa-fC3D/tel+q+/3XRkoHnKGoa70sg" 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: 983 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":"","labelIds":["fcd49e32-5043-4bfd-88a5-2bbe3c95124a","94087865-ce6c-470b-896c-4d1d2c7456b8"],"parentId":"1c2de93f-4321-4015-bfde-ee893ef7976f","teamId":"234c5451-a839-4c8f-98d9-da00973f1060","title":"Sub Issue with Labels"}}}' 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":"bff52c02-bc32-422b-ba31-719c9b0f525f","identifier":"TEST-92","title":"Sub Issue with Labels","description":null,"priority":0,"url":"https://linear.app/linear-mcp-go-test/issue/TEST-92/sub-issue-with-labels","createdAt":"2025-10-06T09:44:41.506Z","updatedAt":"2025-10-06T09:44:41.506Z","state":{"id":"42f7ad15-fca3-4d11-b349-0e3c1385c256","name":"Backlog"},"team":{"id":"234c5451-a839-4c8f-98d9-da00973f1060","name":"Test Team","key":"TEST"},"labels":{"nodes":[{"id":"fcd49e32-5043-4bfd-88a5-2bbe3c95124a","name":"ws-label 2"},{"id":"94087865-ce6c-470b-896c-4d1d2c7456b8","name":"Feature"}]},"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/"2b0-QsIb1mXyrKfU1pD5SHlQtNywn7k" Server: - cloudflare Vary: - Accept-Encoding Via: - 1.1 google status: 200 OK code: 200 duration: 0s ``` -------------------------------------------------------------------------------- /docs/prd/006-issue-comments-pagination.md: -------------------------------------------------------------------------------- ```markdown # Product Requirements Document: Issue Comments Pagination ## Overview This document outlines the requirements for splitting the comment functionality from the `get_issue` tool and implementing a new `get_issue_comments` tool with proper pagination support. ## Background Currently, the `get_issue` tool returns all comments for an issue as part of its response. This approach has several limitations: 1. For issues with many comments, the response can become very large 2. There's no way to paginate through comments 3. There's no way to specifically retrieve replies to a particular comment 4. The client receives more data than might be needed if they're only interested in the issue details ## Requirements ### Functional Requirements 1. Create a new tool named `linear_get_issue_comments` that retrieves comments for a Linear issue 2. Implement pagination for comments to handle potentially long conversations 3. Support retrieving comments from specific threads (top-level or replies to a specific comment) 4. Modify the existing `get_issue` tool to remove the comments section and add a reference to the new tool 5. Ensure backward compatibility by maintaining the same comment formatting style ### Technical Requirements 1. The new tool should accept the following parameters: - `issue`: (required) ID or identifier of the issue to retrieve comments for - `thread`: (optional) UUID of the parent comment to retrieve replies for - `limit`: (optional) Maximum number of comments to return (default: 10) - `after`: (optional) Cursor for pagination to get comments after this point 2. The response should include: - Basic issue information (identifier, UUID) - Thread information (root or parent comment UUID) - List of comments with user, timestamp, and content - Pagination information (has more comments, next cursor) - Indication if comments have replies 3. Update the Linear client to support the new functionality: - Add a `GetIssueComments` method that supports pagination and thread filtering - Define a `GetIssueCommentsInput` struct for the parameters - Define a `PaginatedCommentConnection` struct for the response 4. Register the new tool in the server and add it to the read-only tools list ## Implementation Details ### API Changes The Linear GraphQL API already supports pagination and filtering for comments. We'll use the following query structure: ```graphql query GetIssueComments($issueId: String!, $parentId: String, $first: Int!, $after: String) { issue(id: $issueId) { comments( first: $first, after: $after, filter: { parent: { id: { eq: $parentId } } } ) { nodes { id body createdAt user { id name } childCount } pageInfo { hasNextPage endCursor } totalCount } } } ``` ### Implementation Steps | Step | Description | Status | |------|-------------|--------| | 1 | Add `GetIssueCommentsInput` and `PaginatedCommentConnection` structs to models.go | ✅ | | 2 | Implement `GetIssueComments` method in the Linear client | ✅ | | 3 | Create the `get_issue_comments.go` file with tool definition and handler | ✅ | | 4 | Update `get_issue.go` to remove comments section and add reference to new tool | ✅ | | 5 | Register the new tool in server.go | ✅ | | 6 | Add the new tool to the read-only tools list | ✅ | | 7 | Test the implementation | ✅ | ## Usage Examples ### Example 1: Get top-level comments for an issue ```json { "issue": "TEAM-123", "limit": 5 } ``` ### Example 2: Get replies to a specific comment ```json { "issue": "TEAM-123", "thread": "comment-uuid-here", "limit": 10 } ``` ### Example 3: Paginate through comments ```json { "issue": "TEAM-123", "after": "cursor-from-previous-response", "limit": 10 } ``` ## Benefits 1. **Improved Performance**: Clients can request only the comments they need, reducing payload size 2. **Better User Experience**: Support for pagination allows handling large comment threads efficiently 3. **More Flexibility**: Ability to navigate through specific comment threads 4. **Cleaner API**: Separation of concerns between issue details and comments ## Conclusion The implementation of the `linear_get_issue_comments` tool with pagination support will significantly improve the handling of issue comments, especially for issues with extensive discussions. This change aligns with best practices for API design by providing more granular control over data retrieval and reducing unnecessary data transfer. ``` -------------------------------------------------------------------------------- /pkg/server/resources_test.go: -------------------------------------------------------------------------------- ```go package server import ( "context" "encoding/json" "path/filepath" "testing" "github.com/geropl/linear-mcp-go/pkg/linear" "github.com/google/go-cmp/cmp" "github.com/mark3labs/mcp-go/mcp" mcpserver "github.com/mark3labs/mcp-go/server" ) func TestResourceHandlers(t *testing.T) { // Define test cases tests := []struct { handlerName string name string uri string handlerFunc func(*linear.LinearClient) mcpserver.ResourceHandlerFunc }{ // TeamsResourceHandler test cases { handlerName: "TeamsResourceHandler", name: "List All", uri: "linear://teams", handlerFunc: TeamsResourceHandler, }, // TeamResourceHandler test cases { handlerName: "TeamResourceHandler", name: "Fetch By ID", uri: "linear://team/" + TEAM_ID, handlerFunc: TeamResourceHandler, }, { handlerName: "TeamResourceHandler", name: "Fetch By Name", uri: "linear://team/" + TEAM_NAME, handlerFunc: TeamResourceHandler, }, { handlerName: "TeamResourceHandler", name: "Fetch By Key", uri: "linear://team/" + TEAM_KEY, handlerFunc: TeamResourceHandler, }, { handlerName: "TeamResourceHandler", name: "Invalid ID", uri: "linear://team/invalid-identifier-does-not-exist", // Use a clearly invalid identifier handlerFunc: TeamResourceHandler, }, { handlerName: "TeamResourceHandler", name: "Missing ID", uri: "linear://team/", // Test case where ID is missing from URI path handlerFunc: TeamResourceHandler, }, } for _, tt := range tests { t.Run(tt.handlerName+"_"+tt.name, func(t *testing.T) { // Generate fixture and golden file paths fixtureName := "resource_" + tt.handlerName + "_" + tt.name goldenPath := filepath.Join("../../testdata/golden", fixtureName+".golden") // Create test client with VCR // Use distinct flags for resource tests to avoid conflicts client, cleanup := linear.NewTestClient(t, fixtureName, *record || *recordWrites) defer cleanup() // Get the handler function handler := tt.handlerFunc(client) // Create the request request := mcp.ReadResourceRequest{} request.Params.URI = tt.uri // Call the handler contents, err := handler(context.Background(), request) // Extract the actual output and error var actualOutput, actualErr string if err != nil { actualErr = err.Error() } else { // Marshal the contents to JSON for comparison jsonBytes, jsonErr := json.MarshalIndent(contents, "", " ") // Use indent for readability if jsonErr != nil { t.Fatalf("Failed to marshal resource contents to JSON: %v", jsonErr) } actualOutput = string(jsonBytes) } // If goldenResource flag is set, update the golden file if *golden { writeGoldenFile(t, goldenPath, expectation{ Err: actualErr, Output: actualOutput, }) // Also update the VCR recording implicitly by running the test t.Logf("Updated golden file: %s", goldenPath) // We might need to re-run the test without the golden flag // after recording to ensure the comparison passes. // However, for now, just writing the golden file is the goal. return // Skip comparison when updating golden files } // Otherwise, read the golden file and compare expected := readGoldenFile(t, goldenPath) // Compare error if diff := cmp.Diff(expected.Err, actualErr); diff != "" { t.Errorf("Error mismatch (-want +got):\n%s", diff) } // Compare output (only if no error is expected) if expected.Err == "" && actualErr == "" { // Compare JSON strings directly if diff := cmp.Diff(expected.Output, actualOutput); diff != "" { // To make diffs easier to read, unmarshal and compare structures var expectedContents []mcp.ResourceContents var actualContents []mcp.ResourceContents json.Unmarshal([]byte(expected.Output), &expectedContents) // Ignore error for diffing json.Unmarshal([]byte(actualOutput), &actualContents) // Ignore error for diffing t.Errorf("Output mismatch (-want +got):\n%s", cmp.Diff(expectedContents, actualContents)) t.Logf("Expected JSON:\n%s", expected.Output) t.Logf("Actual JSON:\n%s", actualOutput) } } else if expected.Err == "" && actualErr != "" { t.Errorf("Expected no error, but got: %s", actualErr) } else if expected.Err != "" && actualErr == "" { t.Errorf("Expected error '%s', but got none", expected.Err) } }) } } // readGoldenFile and writeGoldenFile are defined in test_helpers.go ``` -------------------------------------------------------------------------------- /pkg/server/resources.go: -------------------------------------------------------------------------------- ```go package server import ( "context" "encoding/json" "fmt" "strings" "github.com/geropl/linear-mcp-go/pkg/linear" "github.com/mark3labs/mcp-go/mcp" mcpserver "github.com/mark3labs/mcp-go/server" ) // TeamsResource is the resource definition for Linear teams var TeamsResource = mcp.NewResource( "linear://teams", "Linear Teams", mcp.WithResourceDescription("List of teams in Linear"), mcp.WithMIMEType("application/json"), ) // TeamResource is the resource definition for a specific Linear team var TeamResource = mcp.NewResource( "linear://team/{id}", "Linear Team", mcp.WithResourceDescription("Details of a specific team in Linear"), mcp.WithMIMEType("application/json"), ) // RegisterResources registers all Linear resources with the MCP server func RegisterResources(s *mcpserver.MCPServer, linearClient *linear.LinearClient) { // Register Teams resource s.AddResource(TeamsResource, TeamsResourceHandler(linearClient)) // Register Team resource s.AddResource(TeamResource, TeamResourceHandler(linearClient)) } // TeamsResourceHandler handles the linear://teams resource func TeamsResourceHandler(linearClient *linear.LinearClient) mcpserver.ResourceHandlerFunc { return func(ctx context.Context, request mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) { // Get teams from Linear teams, err := linearClient.GetTeams("") if err != nil { return nil, fmt.Errorf("failed to get teams: %v", err) } // Create resource content results := []mcp.ResourceContents{} for _, t := range teams { teamJSON, err := json.Marshal(t) if err != nil { return nil, fmt.Errorf("failed to marshal team: %v", err) } results = append(results, mcp.TextResourceContents{ URI: fmt.Sprintf("linear://team/%s", t.ID), MIMEType: "application/json", Text: string(teamJSON), }) } return results, nil } } // TeamResourceHandler handles the linear://team/{id} resource func TeamResourceHandler(linearClient *linear.LinearClient) mcpserver.ResourceHandlerFunc { return func(ctx context.Context, request mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) { // Extract team ID from URI uri := request.Params.URI if !strings.HasPrefix(uri, "linear://team/") { return nil, fmt.Errorf("invalid team URI: %s", uri) } teamID := uri[len("linear://team/"):] if teamID == "" { return nil, fmt.Errorf("team ID is required") } // Resolve team ID (could be UUID, name, or key) resolvedTeamID, err := resolveTeamIdentifier(linearClient, teamID) if err != nil { return nil, fmt.Errorf("failed to resolve team identifier: %v", err) } // Get all teams and find the matching one teams, err := linearClient.GetTeams("") if err != nil { return nil, fmt.Errorf("failed to get teams: %v", err) } var team *linear.Team for i, t := range teams { if t.ID == resolvedTeamID { team = &teams[i] break } } if team == nil { return nil, fmt.Errorf("team not found: %s", teamID) } // Format team as JSON teamJSON, err := json.Marshal(team) if err != nil { return nil, fmt.Errorf("failed to marshal team: %v", err) } // Create resource content return []mcp.ResourceContents{ mcp.TextResourceContents{ URI: fmt.Sprintf("linear://team/%s", team.ID), MIMEType: "application/json", Text: string(teamJSON), }, }, nil } } // resolveTeamIdentifier resolves a team identifier (UUID, name, or key) to a team ID func resolveTeamIdentifier(linearClient *linear.LinearClient, identifier string) (string, error) { // If it's a valid UUID, use it directly if isValidUUID(identifier) { return identifier, nil } // Otherwise, try to find a team by name or key teams, err := linearClient.GetTeams("") if err != nil { return "", fmt.Errorf("failed to get teams: %v", err) } // First try exact match on name or key for _, team := range teams { if team.Name == identifier || team.Key == identifier { return team.ID, nil } } // If no exact match, try case-insensitive match identifierLower := strings.ToLower(identifier) for _, team := range teams { if strings.ToLower(team.Name) == identifierLower || strings.ToLower(team.Key) == identifierLower { return team.ID, nil } } return "", fmt.Errorf("no team found with identifier '%s'", identifier) } // isValidUUID checks if a string is a valid UUID func isValidUUID(uuidStr string) bool { // Simple UUID validation - check if it has the correct format // This is a simplified version and doesn't validate the UUID fully return len(uuidStr) == 36 && strings.Count(uuidStr, "-") == 4 } ``` -------------------------------------------------------------------------------- /testdata/fixtures/create_issue_handler_Valid issue with team key.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 - id: 1 request: proto: HTTP/1.1 proto_major: 1 proto_minor: 1 content_length: 845 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 key"}}}' 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":"bf164a25-82c9-4cd6-aaeb-c83c8dbf09b0","identifier":"TEST-88","title":"Test Issue with team key","description":null,"priority":0,"url":"https://linear.app/linear-mcp-go-test/issue/TEST-88/test-issue-with-team-key","createdAt":"2025-10-06T09:44:21.430Z","updatedAt":"2025-10-06T09:44:21.430Z","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/"236-rqBxky5hsQ9s0ISmkxV2klYfYBg" 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 name.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 - id: 1 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 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: | {"data":{"issueCreate":{"success":true,"issue":{"id":"aaf77828-2cd0-413f-977d-1e66968de5f6","identifier":"TEST-87","title":"Test Issue with team name","description":null,"priority":0,"url":"https://linear.app/linear-mcp-go-test/issue/TEST-87/test-issue-with-team-name","createdAt":"2025-10-06T09:44:13.623Z","updatedAt":"2025-10-06T09:44:13.623Z","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-a41QzB6Eb98+OtI+B5Rg9E9GPa0" Server: - cloudflare Vary: - Accept-Encoding Via: - 1.1 google status: 200 OK code: 200 duration: 0s ``` -------------------------------------------------------------------------------- /docs/prd/000-tool-standardization-overview.md: -------------------------------------------------------------------------------- ```markdown # Linear MCP Server Tool Standardization ## Executive Summary This document provides an overview of the Linear MCP Server Tool Standardization initiative. The goal is to establish and implement consistent rules across all tools in the Linear MCP Server, improving user experience, code maintainability, and overall quality. ## Documents in this Series 1. [**000-tool-standardization-overview.md**](./000-tool-standardization-overview.md) (this document) - Executive summary and overview of the standardization effort 2. [**002-tool-standardization.md**](./002-tool-standardization.md) - Detailed requirements and rationale for the standardization rules - Analysis of current state and implementation plan 3. [**003-tool-standardization-implementation.md**](./003-tool-standardization-implementation.md) - Detailed implementation guide with specific tasks for each tool - Example implementations and code structure changes 4. [**004-tool-standardization-tracking.md**](./004-tool-standardization-tracking.md) - Tracking sheet for implementation progress - Detailed task breakdown and status tracking 5. [**005-sample-implementation.md**](./005-sample-implementation.md) - Sample code for key components - Testing examples and implementation strategy ## Standardization Rules ### Rule 1: Concise Tool Descriptions Tool descriptions should be concise and focus only on the tool's purpose and functionality, without listing parameters or explaining the result format. ### Rule 2: Flexible Object Identifier Resolution Input arguments that reference Linear objects should accept multiple forms of identification (UUID, name, key) and resolve them to the underlying UUID using consistent resolution methods. ### Rule 3: Consistent Entity Rendering Tools fetching the same entities should emit results using the same format, with additional fields added at the bottom. This includes: 1. **Full Entity Rendering**: When displaying an entity as the primary subject of a response, use a consistent format with all required fields. 2. **Entity Identifier Rendering**: When referencing an entity from another entity, use a consistent, concise identifier format. ### Rule 4: Field Superset for Retrieval Methods The fields rendered on retrieval methods should follow a consistent pattern: - **Detail retrieval methods** must include all fields that can be set through create/update methods - **Overview retrieval methods** only need to include key metadata fields This ensures appropriate level of detail while maintaining consistency across the API. ## Benefits 1. **Improved User Experience** - Consistent behavior across all tools - More flexible parameter handling - Standardized output format 2. **Enhanced Code Maintainability** - Shared utility functions for common operations - Reduced code duplication - Consistent patterns across the codebase 3. **Better Quality** - Standardized error handling - Consistent validation - Comprehensive testing ## Implementation Approach The implementation will follow a phased approach: 1. **Phase 1: Create Shared Utility Functions** - Develop common identifier resolution functions - Create entity rendering functions - Establish consistent patterns 2. **Phase 2: Update Tools** - Update each tool to follow the standardization rules - Start with one tool as a reference implementation - Apply the same patterns to all remaining tools 3. **Phase 3: Update Tests** - Update test fixtures to reflect the new formatting - Add tests for the new utility functions - Verify all tests pass with the new implementation ## Timeline and Resources | Phase | Estimated Duration | Dependencies | |-------|-------------------|--------------| | Phase 1: Create Shared Utility Functions | 1 day | None | | Phase 2: Update Tools | 3 days | Phase 1 | | Phase 3: Update Tests | 1 day | Phase 2 | | **Total** | **5 days** | | ## Success Criteria The standardization effort will be considered successful when: 1. All tool descriptions are concise and focused on functionality 2. All tools that reference Linear objects accept multiple identifier types 3. All tools render entities in a consistent format 4. Retrieval methods include all fields that can be set in create/update methods 5. Code reuse is maximized through shared functions 6. All tests pass with the new implementation ## Next Steps 1. Review and approve the standardization requirements 2. Begin implementation of Phase 1 (Shared Utility Functions) 3. Select a tool to serve as the reference implementation 4. Implement changes for all tools 5. Update tests and verify functionality ``` -------------------------------------------------------------------------------- /pkg/tools/initiative_tools.go: -------------------------------------------------------------------------------- ```go package tools import ( "context" "fmt" "strings" "github.com/geropl/linear-mcp-go/pkg/linear" "github.com/mark3labs/mcp-go/mcp" ) var GetInitiativeTool = mcp.NewTool("linear_get_initiative", mcp.WithDescription("Get a single initiative by its identifier (ID or name)."), mcp.WithString("initiative", mcp.Required(), mcp.Description("The identifier of the initiative to get. Can be the initiative's ID or name.")), ) func GetInitiativeHandler(linearClient *linear.LinearClient) func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { initiativeIdentifier, err := request.RequireString("initiative") if err != nil { return &mcp.CallToolResult{IsError: true, Content: []mcp.Content{mcp.TextContent{Type: "text", Text: err.Error()}}}, nil } initiative, err := linearClient.GetInitiative(initiativeIdentifier) if err != nil { return &mcp.CallToolResult{IsError: true, Content: []mcp.Content{mcp.TextContent{Type: "text", Text: fmt.Sprintf("Failed to get initiative: %v", err)}}}, nil } resultText := FormatInitiative(*initiative) return &mcp.CallToolResult{Content: []mcp.Content{mcp.TextContent{Type: "text", Text: resultText}}}, nil } } func FormatInitiative(initiative linear.Initiative) string { var builder strings.Builder builder.WriteString(fmt.Sprintf("Initiative: %s\n", initiative.Name)) builder.WriteString(fmt.Sprintf(" ID: %s\n", initiative.ID)) if initiative.Description != "" { builder.WriteString(fmt.Sprintf(" Description: %s\n", initiative.Description)) } builder.WriteString(fmt.Sprintf(" URL: %s\n", initiative.URL)) return builder.String() } var CreateInitiativeTool = mcp.NewTool("linear_create_initiative", mcp.WithDescription("Create a new initiative."), mcp.WithString("name", mcp.Required(), mcp.Description("The name of the initiative.")), mcp.WithString("description", mcp.Description("The description of the initiative.")), ) func CreateInitiativeHandler(linearClient *linear.LinearClient) func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { name, err := request.RequireString("name") if err != nil { return &mcp.CallToolResult{IsError: true, Content: []mcp.Content{mcp.TextContent{Type: "text", Text: err.Error()}}}, nil } description := request.GetString("description", "") input := linear.InitiativeCreateInput{ Name: name, Description: description, } initiative, err := linearClient.CreateInitiative(input) if err != nil { return &mcp.CallToolResult{IsError: true, Content: []mcp.Content{mcp.TextContent{Type: "text", Text: fmt.Sprintf("Failed to create initiative: %v", err)}}}, nil } resultText := FormatInitiative(*initiative) return &mcp.CallToolResult{Content: []mcp.Content{mcp.TextContent{Type: "text", Text: resultText}}}, nil } } var UpdateInitiativeTool = mcp.NewTool("linear_update_initiative", mcp.WithDescription("Update an existing initiative."), mcp.WithString("initiative", mcp.Required(), mcp.Description("The ID or name of the initiative to update.")), mcp.WithString("name", mcp.Description("The new name of the initiative.")), mcp.WithString("description", mcp.Description("The new description of the initiative.")), ) func UpdateInitiativeHandler(linearClient *linear.LinearClient) func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { initiativeIdentifier, err := request.RequireString("initiative") if err != nil { return &mcp.CallToolResult{IsError: true, Content: []mcp.Content{mcp.TextContent{Type: "text", Text: err.Error()}}}, nil } // Get the initiative first to get its ID init, err := linearClient.GetInitiative(initiativeIdentifier) if err != nil { return &mcp.CallToolResult{IsError: true, Content: []mcp.Content{mcp.TextContent{Type: "text", Text: fmt.Sprintf("Failed to get initiative: %v", err)}}}, nil } name := request.GetString("name", "") description := request.GetString("description", "") input := linear.InitiativeUpdateInput{ Name: name, Description: description, } initiative, err := linearClient.UpdateInitiative(init.ID, input) if err != nil { return &mcp.CallToolResult{IsError: true, Content: []mcp.Content{mcp.TextContent{Type: "text", Text: fmt.Sprintf("Failed to update initiative: %v", err)}}}, nil } resultText := FormatInitiative(*initiative) return &mcp.CallToolResult{Content: []mcp.Content{mcp.TextContent{Type: "text", Text: resultText}}}, nil } } ``` -------------------------------------------------------------------------------- /pkg/tools/rendering.go: -------------------------------------------------------------------------------- ```go package tools import ( "fmt" "strings" "github.com/geropl/linear-mcp-go/pkg/linear" ) // Full Entity Rendering Functions // formatIssue returns a consistently formatted full representation of an issue func formatIssue(issue *linear.Issue) string { if issue == nil { return "Issue: Unknown" } var result strings.Builder result.WriteString(fmt.Sprintf("Issue: %s (UUID: %s)\n", issue.Identifier, issue.ID)) result.WriteString(fmt.Sprintf("Title: %s\n", issue.Title)) result.WriteString(fmt.Sprintf("URL: %s\n", issue.URL)) result.WriteString(fmt.Sprintf("Priority: %s\n", priorityToString(issue.Priority))) statusStr := "None" if issue.Status != "" { statusStr = issue.Status } else if issue.State != nil { statusStr = issue.State.Name } result.WriteString(fmt.Sprintf("Status: %s\n", statusStr)) // Include labels if available if issue.Labels != nil && len(issue.Labels.Nodes) > 0 { labelNames := make([]string, 0, len(issue.Labels.Nodes)) for _, label := range issue.Labels.Nodes { labelNames = append(labelNames, label.Name) } result.WriteString(fmt.Sprintf("Labels: %s\n", strings.Join(labelNames, ", "))) } else { result.WriteString("Labels: None\n") } // Include description if issue.Description != "" { result.WriteString(fmt.Sprintf("Description: %s\n", issue.Description)) } else { result.WriteString("Description: None\n") } return result.String() } // formatTeam returns a consistently formatted full representation of a team func formatTeam(team *linear.Team) string { if team == nil { return "Team: Unknown" } var result strings.Builder result.WriteString(fmt.Sprintf("Team: %s (UUID: %s)\n", team.Name, team.ID)) result.WriteString(fmt.Sprintf("Key: %s\n", team.Key)) return result.String() } // formatUser returns a consistently formatted full representation of a user func formatUser(user *linear.User) string { if user == nil { return "User: Unknown" } var result strings.Builder result.WriteString(fmt.Sprintf("User: %s (UUID: %s)\n", user.Name, user.ID)) if user.Email != "" { result.WriteString(fmt.Sprintf("Email: %s\n", user.Email)) } return result.String() } // formatComment returns a consistently formatted full representation of a comment func formatComment(comment *linear.Comment) string { if comment == nil { return "Comment: Unknown" } userName := "Unknown" if comment.User != nil { userName = comment.User.Name } var result strings.Builder result.WriteString(fmt.Sprintf("Comment by %s (UUID: %s)\n", userName, comment.ID)) result.WriteString(fmt.Sprintf("Body: %s\n", comment.Body)) if !comment.CreatedAt.IsZero() { result.WriteString(fmt.Sprintf("Created: %s\n", comment.CreatedAt.Format("2006-01-02 15:04:05"))) } return result.String() } // Entity Identifier Rendering Functions // formatIssueIdentifier returns a consistently formatted identifier for an issue func formatIssueIdentifier(issue *linear.Issue) string { if issue == nil { return "Issue: Unknown" } return fmt.Sprintf("Issue: %s (UUID: %s)", issue.Identifier, issue.ID) } // formatTeamIdentifier returns a consistently formatted identifier for a team func formatTeamIdentifier(team *linear.Team) string { if team == nil { return "Team: Unknown" } return fmt.Sprintf("Team: %s (UUID: %s)", team.Name, team.ID) } // formatUserIdentifier returns a consistently formatted identifier for a user func formatUserIdentifier(user *linear.User) string { if user == nil { return "User: Unknown" } return fmt.Sprintf("User: %s (UUID: %s)", user.Name, user.ID) } // formatCommentIdentifier returns a consistently formatted identifier for a comment func formatCommentIdentifier(comment *linear.Comment) string { if comment == nil { return "Unknown" } return fmt.Sprintf(comment.ID) } // formatNewComment returns a consistently formatted response for a newly created comment // parentID should be the UUID of the parent comment if this is a reply, empty string otherwise func formatNewComment(comment *linear.Comment, issue *linear.Issue, parentID string) string { if comment == nil || issue == nil { return "Error: Invalid comment or issue" } var result strings.Builder // Format based on whether this is a reply or top-level comment if parentID != "" { // This is a reply result.WriteString(fmt.Sprintf("Replied with Comment: %s\n", comment.ID)) result.WriteString(fmt.Sprintf("to Thread: %s\n", parentID)) result.WriteString(fmt.Sprintf("on %s\n", formatIssueIdentifier(issue))) } else { // This is a top-level comment result.WriteString(fmt.Sprintf("Added Comment: %s\n", comment.ID)) result.WriteString(fmt.Sprintf("to %s\n", formatIssueIdentifier(issue))) } result.WriteString(fmt.Sprintf("URL: %s", comment.URL)) return result.String() } ``` -------------------------------------------------------------------------------- /pkg/tools/search_issues.go: -------------------------------------------------------------------------------- ```go package tools import ( "context" "fmt" "strings" "github.com/geropl/linear-mcp-go/pkg/linear" "github.com/mark3labs/mcp-go/mcp" ) // SearchIssuesTool is the tool definition for searching issues var SearchIssuesTool = mcp.NewTool("linear_search_issues", mcp.WithDescription("Searches Linear issues."), mcp.WithString("query", mcp.Description("Optional text to search in title and description")), mcp.WithString("team", mcp.Description("Filter by team identifier (UUID, name, or key)")), mcp.WithString("status", mcp.Description("Filter by status name (e.g., 'In Progress', 'Done')")), mcp.WithString("assignee", mcp.Description("Filter by assignee identifier (UUID, name, or email)")), mcp.WithString("labels", mcp.Description("Filter by label names (comma-separated)")), mcp.WithString("priority", getPriorityOptions()...), mcp.WithNumber("estimate", mcp.Description("Filter by estimate points")), mcp.WithBoolean("includeArchived", mcp.Description("Include archived issues in results (default: false)")), mcp.WithNumber("limit", mcp.Description("Max results to return (default: 10)")), ) // SearchIssuesHandler handles the linear_search_issues tool func SearchIssuesHandler(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 search input input := linear.SearchIssuesInput{} input.Query = request.GetString("query", "") if team, err := request.RequireString("team"); err == nil && 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 } input.TeamID = teamID } input.Status = request.GetString("status", "") if assignee, err := request.RequireString("assignee"); err == nil && assignee != "" { // Resolve assignee identifier to a user ID assigneeID, err := resolveUserIdentifier(linearClient, assignee) if err != nil { return &mcp.CallToolResult{IsError: true, Content: []mcp.Content{mcp.TextContent{Type: "text", Text: fmt.Sprintf("Failed to resolve assignee: %v", err)}}}, nil } input.AssigneeID = assigneeID } if labelsStr, err := request.RequireString("labels"); err == nil && labelsStr != "" { // Split comma-separated labels labels := []string{} for _, label := range strings.Split(labelsStr, ",") { trimmedLabel := strings.TrimSpace(label) if trimmedLabel != "" { labels = append(labels, trimmedLabel) } } input.Labels = labels } if priorityStr, err := request.RequireString("priority"); err == nil && priorityStr != "" { priority, 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 } input.Priority = &priority } if estimate, err := request.RequireFloat("estimate"); err == nil { input.Estimate = &estimate } input.IncludeArchived = request.GetBool("includeArchived", false) input.Limit = request.GetInt("limit", 10) // Search for issues issues, err := linearClient.SearchIssues(input) if err != nil { return &mcp.CallToolResult{IsError: true, Content: []mcp.Content{mcp.TextContent{Type: "text", Text: fmt.Sprintf("Failed to search 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, } priorityStr := priorityToString(issue.Priority) 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", priorityStr) resultText += fmt.Sprintf(" Status: %s\n", statusStr) if issue.Project != nil { resultText += fmt.Sprintf(" Project: %s (%s)\n", issue.Project.Name, issue.Project.ID) } else { resultText += " Project: None\n" } if issue.ProjectMilestone != nil { resultText += fmt.Sprintf(" Milestone: %s (%s)\n", issue.ProjectMilestone.Name, issue.ProjectMilestone.ID) } else { resultText += " Milestone: None\n" } resultText += fmt.Sprintf(" URL: %s\n", issue.URL) } return &mcp.CallToolResult{Content: []mcp.Content{mcp.TextContent{Type: "text", Text: resultText}}}, nil } } ``` -------------------------------------------------------------------------------- /testdata/fixtures/get_issue_handler_Valid issue.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: 1316 transfer_encoding: [] trailer: {} host: api.linear.app remote_addr: "" request_uri: "" body: '{"query":"\n\t\tquery GetIssue($id: String!) {\n\t\t\tissue(id: $id) {\n\t\t\t\tid\n\t\t\t\tidentifier\n\t\t\t\ttitle\n\t\t\t\tdescription\n\t\t\t\tpriority\n\t\t\t\turl\n\t\t\t\tcreatedAt\n\t\t\t\tupdatedAt\n\t\t\t\tstate {\n\t\t\t\t\tid\n\t\t\t\t\tname\n\t\t\t\t}\n\t\t\t\tassignee {\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\tteam {\n\t\t\t\t\tid\n\t\t\t\t\tname\n\t\t\t\t\tkey\n\t\t\t\t}\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\tprojectMilestone {\n\t\t\t\t\tid\n\t\t\t\t\tname\n\t\t\t\t}\n\t\t\t\trelations(first: 20) {\n\t\t\t\t\tnodes {\n\t\t\t\t\t\tid\n\t\t\t\t\t\ttype\n\t\t\t\t\t\trelatedIssue {\n\t\t\t\t\t\t\tid\n\t\t\t\t\t\t\tidentifier\n\t\t\t\t\t\t\ttitle\n\t\t\t\t\t\t\turl\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tinverseRelations(first: 20) {\n\t\t\t\t\tnodes {\n\t\t\t\t\t\tid\n\t\t\t\t\t\ttype\n\t\t\t\t\t\tissue {\n\t\t\t\t\t\t\tid\n\t\t\t\t\t\t\tidentifier\n\t\t\t\t\t\t\ttitle\n\t\t\t\t\t\t\turl\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tattachments(first: 50) {\n\t\t\t\t\tnodes {\n\t\t\t\t\t\tid\n\t\t\t\t\t\ttitle\n\t\t\t\t\t\tsubtitle\n\t\t\t\t\t\turl\n\t\t\t\t\t\tsourceType\n\t\t\t\t\t\tmetadata\n\t\t\t\t\t\tcreatedAt\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t","variables":{"id":"1c2de93f-4321-4015-bfde-ee893ef7976f"}}' 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":{"issue":{"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","createdAt":"2025-03-03T11:34:49.241Z","updatedAt":"2025-06-28T19:53:27.855Z","state":{"id":"42f7ad15-fca3-4d11-b349-0e3c1385c256","name":"Backlog"},"assignee":{"id":"cc24eee4-9edc-4bfe-b91b-fedde125ba85","name":"Gero Leinemann","email":"[email protected]"},"team":{"id":"234c5451-a839-4c8f-98d9-da00973f1060","name":"Test Team","key":"TEST"},"project":{"id":"01bff2dd-ab7f-4464-b425-97073862013f","name":"MCP tool investigation"},"projectMilestone":{"id":"5214c4d9-9c2a-4ae7-b5e5-e33058b3e131","name":"M1: Gather potential resources to investigate"},"relations":{"nodes":[]},"inverseRelations":{"nodes":[]},"attachments":{"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/"36b-tillFLIUMm8VXol85JbmMotLYUg" Server: - cloudflare Vary: - Accept-Encoding Via: - 1.1 google status: 200 OK code: 200 duration: 0s ``` -------------------------------------------------------------------------------- /testdata/fixtures/get_issue_handler_Get comment issue.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":12,"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":"9407c793-5fd8-4730-9280-0e17ffddf320","identifier":"TEST-12","title":"Comments 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/"7e-a2LOPkL8PZhOQop7X2YpU+ZF/Y8" 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: 1316 transfer_encoding: [] trailer: {} host: api.linear.app remote_addr: "" request_uri: "" body: '{"query":"\n\t\tquery GetIssue($id: String!) {\n\t\t\tissue(id: $id) {\n\t\t\t\tid\n\t\t\t\tidentifier\n\t\t\t\ttitle\n\t\t\t\tdescription\n\t\t\t\tpriority\n\t\t\t\turl\n\t\t\t\tcreatedAt\n\t\t\t\tupdatedAt\n\t\t\t\tstate {\n\t\t\t\t\tid\n\t\t\t\t\tname\n\t\t\t\t}\n\t\t\t\tassignee {\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\tteam {\n\t\t\t\t\tid\n\t\t\t\t\tname\n\t\t\t\t\tkey\n\t\t\t\t}\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\tprojectMilestone {\n\t\t\t\t\tid\n\t\t\t\t\tname\n\t\t\t\t}\n\t\t\t\trelations(first: 20) {\n\t\t\t\t\tnodes {\n\t\t\t\t\t\tid\n\t\t\t\t\t\ttype\n\t\t\t\t\t\trelatedIssue {\n\t\t\t\t\t\t\tid\n\t\t\t\t\t\t\tidentifier\n\t\t\t\t\t\t\ttitle\n\t\t\t\t\t\t\turl\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tinverseRelations(first: 20) {\n\t\t\t\t\tnodes {\n\t\t\t\t\t\tid\n\t\t\t\t\t\ttype\n\t\t\t\t\t\tissue {\n\t\t\t\t\t\t\tid\n\t\t\t\t\t\t\tidentifier\n\t\t\t\t\t\t\ttitle\n\t\t\t\t\t\t\turl\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tattachments(first: 50) {\n\t\t\t\t\tnodes {\n\t\t\t\t\t\tid\n\t\t\t\t\t\ttitle\n\t\t\t\t\t\tsubtitle\n\t\t\t\t\t\turl\n\t\t\t\t\t\tsourceType\n\t\t\t\t\t\tmetadata\n\t\t\t\t\t\tcreatedAt\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t","variables":{"id":"9407c793-5fd8-4730-9280-0e17ffddf320"}}' 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":{"issue":{"id":"9407c793-5fd8-4730-9280-0e17ffddf320","identifier":"TEST-12","title":"Comments issue","description":"This is the description","priority":0,"url":"https://linear.app/linear-mcp-go-test/issue/TEST-12/comments-issue","createdAt":"2025-03-04T08:40:53.877Z","updatedAt":"2025-03-04T08:43:37.989Z","state":{"id":"cffb8999-f10e-447d-9672-8faf5b06ac67","name":"Todo"},"assignee":null,"team":{"id":"234c5451-a839-4c8f-98d9-da00973f1060","name":"Test Team","key":"TEST"},"project":null,"projectMilestone":null,"relations":{"nodes":[]},"inverseRelations":{"nodes":[]},"attachments":{"nodes":[{"id":"cf677e8d-955f-430e-b281-4ee9bde7df79","title":"[docs] Getting Started","subtitle":"Gitpod Documentation: Learn how to start your first Gitpod workspace for free, set up a gitpod.yml configuration file and enable Prebuilds.","url":"https://www.gitpod.io/docs/introduction/getting-started","sourceType":"api","metadata":{},"createdAt":"2025-03-04T08:43:37.989Z"}]}}}} headers: Alt-Svc: - h3=":443"; ma=86400 Cache-Control: - no-store Cf-Cache-Status: - DYNAMIC Content-Type: - application/json; charset=utf-8 Etag: - W/"3d2-y7Op6fHSC2Lvc4f+0aw4k03LArM" Server: - cloudflare Vary: - Accept-Encoding Via: - 1.1 google status: 200 OK code: 200 duration: 0s ``` -------------------------------------------------------------------------------- /testdata/fixtures/get_project_handler_By name.yaml: -------------------------------------------------------------------------------- ```yaml --- version: 2 interactions: - id: 0 request: proto: HTTP/1.1 proto_major: 1 proto_minor: 1 content_length: 719 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":"MCP tool investigation"}}' 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: Project","path":["project"],"locations":[{"line":3,"column":4}],"extensions":{"type":"invalid input","code":"INPUT_ERROR","statusCode":400,"userError":true,"userPresentableMessage":"Could not find referenced Project."}}],"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/"113-pUQ9mkDn3KWYiQz0UBE51+d7gJ4" 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: 907 transfer_encoding: [] trailer: {} host: api.linear.app remote_addr: "" request_uri: "" body: '{"query":"\n\t\tquery GetProjectByNameOrSlug($filter: ProjectFilter) {\n\t\t\tprojects(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\tslugId\n\t\t\t\t\tstate\n\t\t\t\t\turl\n\t\t\t\t\tcreatedAt\n\t\t\t\t\tupdatedAt\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\temail\n\t\t\t\t\t}\n\t\t\t\t\tmembers {\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\temail\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\tteams {\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\tkey\n\t\t\t\t\t\t}\n\t\t\t\t\t}\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\tstartDate\n\t\t\t\t\ttargetDate\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t","variables":{"filter":{"or":[{"name":{"eq":"MCP tool investigation"}},{"slugId":{"eq":""}}]}}}' 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","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/"355-Jji1j11utIAgJU/7ATKvhRyba4g" Server: - cloudflare Vary: - Accept-Encoding Via: - 1.1 google status: 200 OK code: 200 duration: 0s ``` -------------------------------------------------------------------------------- /pkg/tools/create_issue.go: -------------------------------------------------------------------------------- ```go package tools import ( "context" "fmt" "strings" "github.com/geropl/linear-mcp-go/pkg/linear" "github.com/mark3labs/mcp-go/mcp" ) // CreateIssueTool is the tool definition for creating issues var CreateIssueTool = mcp.NewTool("linear_create_issue", mcp.WithDescription("Creates a new Linear issue."), mcp.WithString("title", mcp.Required(), mcp.Description("Issue title")), mcp.WithString("team", mcp.Required(), mcp.Description("Team identifier (key, UUID or name)")), mcp.WithString("description", mcp.Description("Issue description")), mcp.WithString("priority", getPriorityOptions()...), mcp.WithString("status", mcp.Description("Issue status")), mcp.WithString("makeSubissueOf", mcp.Description("Makes this issue a sub-issue of the specified parent. Accepts issue ID (UUID) or identifier (e.g., 'TEAM-123'). Creates a parent-child relationship in Linear.")), mcp.WithString("labels", mcp.Description("Optional comma-separated list of label IDs or names to assign")), mcp.WithString("project", mcp.Description("Optional project identifier (ID, name, or slug) to assign the issue to")), ) // CreateIssueHandler handles the linear_create_issue tool func CreateIssueHandler(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 title, err := request.RequireString("title") if err != nil { return &mcp.CallToolResult{IsError: true, Content: []mcp.Content{mcp.TextContent{Type: "text", Text: err.Error()}}}, nil } team, err := request.RequireString("team") if err != nil { return &mcp.CallToolResult{IsError: true, Content: []mcp.Content{mcp.TextContent{Type: "text", Text: err.Error()}}}, nil } // 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 } // Extract optional arguments 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 } status := request.GetString("status", "") // Extract makeSubissueOf parameter and resolve it if needed var parentID *string if parentIssue, err := request.RequireString("makeSubissueOf"); err == nil && parentIssue != "" { resolvedParentID, err := resolveIssueIdentifier(linearClient, parentIssue) if err != nil { return &mcp.CallToolResult{IsError: true, Content: []mcp.Content{mcp.TextContent{Type: "text", Text: fmt.Sprintf("Failed to resolve parent issue: %v", err)}}}, nil } parentID = &resolvedParentID } // Extract labels parameter and resolve them if needed var labelIDs []string if labelsStr, err := request.RequireString("labels"); err == nil && labelsStr != "" { // Split comma-separated labels var labelIdentifiers []string for _, label := range strings.Split(labelsStr, ",") { trimmedLabel := strings.TrimSpace(label) if trimmedLabel != "" { labelIdentifiers = append(labelIdentifiers, trimmedLabel) } } // Resolve label identifiers to UUIDs if len(labelIdentifiers) > 0 { resolvedLabelIDs, err := resolveLabelIdentifiers(linearClient, teamId, labelIdentifiers) if err != nil { return &mcp.CallToolResult{IsError: true, Content: []mcp.Content{mcp.TextContent{Type: "text", Text: fmt.Sprintf("Failed to resolve labels: %v", err)}}}, nil } labelIDs = resolvedLabelIDs } } // Extract project parameter and resolve it if needed var projectID string if project, err := request.RequireString("project"); err == nil && project != "" { resolvedProjectID, err := resolveProjectIdentifier(linearClient, project) if err != nil { return &mcp.CallToolResult{IsError: true, Content: []mcp.Content{mcp.TextContent{Type: "text", Text: fmt.Sprintf("Failed to resolve project: %v", err)}}}, nil } projectID = resolvedProjectID } // Create the issue input := linear.CreateIssueInput{ Title: title, TeamID: teamId, Description: description, Priority: priority, Status: status, ParentID: parentID, LabelIDs: labelIDs, ProjectID: projectID, } issue, err := linearClient.CreateIssue(input) if err != nil { return &mcp.CallToolResult{IsError: true, Content: []mcp.Content{mcp.TextContent{Type: "text", Text: fmt.Sprintf("Failed to create issue: %v", err)}}}, nil } // Return the result resultText := fmt.Sprintf("Created %s", formatIssueIdentifier(issue)) resultText += fmt.Sprintf("\nTitle: %s", issue.Title) resultText += fmt.Sprintf("\nURL: %s", issue.URL) return &mcp.CallToolResult{Content: []mcp.Content{mcp.TextContent{Type: "text", Text: resultText}}}, nil } } ```