This is page 3 of 6. Use http://codebase.md/geropl/linear-mcp-go?lines=true&page={x} to view the full context. # Directory Structure ``` ├── .clinerules │ └── memory-bank.md ├── .devcontainer │ ├── devcontainer.json │ └── Dockerfile ├── .github │ └── workflows │ └── release.yml ├── .gitignore ├── .gitpod.yml ├── cmd │ ├── root.go │ ├── serve.go │ ├── setup_test.go │ ├── setup.go │ └── version.go ├── docs │ ├── design │ │ ├── 001-mcp-go-upgrade.md │ │ └── 002-project-milestone-initiative.md │ └── prd │ ├── 000-tool-standardization-overview.md │ ├── 001-api-refresher.md │ ├── 002-tool-standardization.md │ ├── 003-tool-standardization-implementation.md │ ├── 004-tool-standardization-tracking.md │ ├── 005-sample-implementation.md │ ├── 006-issue-comments-pagination.md │ └── README.md ├── go.mod ├── go.sum ├── main.go ├── memory-bank │ ├── activeContext.md │ ├── developmentWorkflows.md │ ├── productContext.md │ ├── progress.md │ ├── projectbrief.md │ ├── systemPatterns.md │ └── techContext.md ├── pkg │ ├── linear │ │ ├── client.go │ │ ├── models.go │ │ ├── rate_limiter.go │ │ └── test_helpers.go │ ├── server │ │ ├── resources_test.go │ │ ├── resources.go │ │ ├── server.go │ │ ├── test_helpers.go │ │ └── tools_test.go │ └── tools │ ├── add_comment.go │ ├── common.go │ ├── create_issue.go │ ├── get_issue_comments.go │ ├── get_issue.go │ ├── get_teams.go │ ├── get_user_issues.go │ ├── initiative_tools.go │ ├── milestone_tools.go │ ├── priority_test.go │ ├── priority.go │ ├── project_tools.go │ ├── rendering.go │ ├── reply_to_comment.go │ ├── search_issues.go │ ├── update_issue_comment.go │ └── update_issue.go ├── README.md ├── scripts │ └── register-cline.sh └── testdata ├── fixtures │ ├── add_comment_handler_Missing body.yaml │ ├── add_comment_handler_Missing issue.yaml │ ├── add_comment_handler_Missing issueId.yaml │ ├── add_comment_handler_Reply with shorthand.yaml │ ├── add_comment_handler_Reply with URL.yaml │ ├── add_comment_handler_Reply_to_comment.yaml │ ├── add_comment_handler_Valid comment.yaml │ ├── create_initiative_handler_Missing name.yaml │ ├── create_initiative_handler_Valid initiative.yaml │ ├── create_initiative_handler_With description.yaml │ ├── create_issue_handler_Create issue with invalid project.yaml │ ├── create_issue_handler_Create issue with labels.yaml │ ├── create_issue_handler_Create issue with project ID.yaml │ ├── create_issue_handler_Create issue with project name.yaml │ ├── create_issue_handler_Create issue with project slug.yaml │ ├── create_issue_handler_Create sub issue from identifier.yaml │ ├── create_issue_handler_Create sub issue with labels.yaml │ ├── create_issue_handler_Create sub issue.yaml │ ├── create_issue_handler_Invalid team.yaml │ ├── create_issue_handler_Missing team.yaml │ ├── create_issue_handler_Missing teamId.yaml │ ├── create_issue_handler_Missing title.yaml │ ├── create_issue_handler_Valid issue with team key.yaml │ ├── create_issue_handler_Valid issue with team name.yaml │ ├── create_issue_handler_Valid issue with team UUID.yaml │ ├── create_issue_handler_Valid issue with team.yaml │ ├── create_issue_handler_Valid issue with teamId.yaml │ ├── create_issue_handler_Valid issue.yaml │ ├── create_milestone_handler_Invalid project ID.yaml │ ├── create_milestone_handler_Missing name.yaml │ ├── create_milestone_handler_Valid milestone.yaml │ ├── create_milestone_handler_With all optional fields.yaml │ ├── create_project_handler_Invalid team ID.yaml │ ├── create_project_handler_Missing name.yaml │ ├── create_project_handler_Valid project.yaml │ ├── create_project_handler_With all optional fields.yaml │ ├── get_initiative_handler_By name.yaml │ ├── get_initiative_handler_Non-existent name.yaml │ ├── get_initiative_handler_Valid initiative.yaml │ ├── get_issue_comments_handler_Invalid issue.yaml │ ├── get_issue_comments_handler_Missing issue.yaml │ ├── get_issue_comments_handler_Thread_with_pagination.yaml │ ├── get_issue_comments_handler_Valid issue.yaml │ ├── get_issue_comments_handler_With limit.yaml │ ├── get_issue_comments_handler_With_thread_parameter.yaml │ ├── get_issue_handler_Get comment issue.yaml │ ├── get_issue_handler_Missing issue.yaml │ ├── get_issue_handler_Missing issueId.yaml │ ├── get_issue_handler_Valid issue.yaml │ ├── get_milestone_handler_By name.yaml │ ├── get_milestone_handler_Non-existent milestone.yaml │ ├── get_milestone_handler_Valid milestone.yaml │ ├── get_project_handler_By ID.yaml │ ├── get_project_handler_By name.yaml │ ├── get_project_handler_By slug.yaml │ ├── get_project_handler_Invalid project.yaml │ ├── get_project_handler_Missing project param.yaml │ ├── get_project_handler_Non-existent slug.yaml │ ├── get_teams_handler_Get Teams.yaml │ ├── get_user_issues_handler_Current user issues.yaml │ ├── get_user_issues_handler_Specific user issues.yaml │ ├── reply_to_comment_handler_Missing body.yaml │ ├── reply_to_comment_handler_Missing thread.yaml │ ├── reply_to_comment_handler_Reply with URL.yaml │ ├── reply_to_comment_handler_Valid reply.yaml │ ├── resource_TeamResourceHandler_Fetch By ID.yaml │ ├── resource_TeamResourceHandler_Fetch By Key.yaml │ ├── resource_TeamResourceHandler_Fetch By Name.yaml │ ├── resource_TeamResourceHandler_Invalid ID.yaml │ ├── resource_TeamResourceHandler_Missing ID.yaml │ ├── resource_TeamsResourceHandler_List All.yaml │ ├── search_issues_handler_Search by query.yaml │ ├── search_issues_handler_Search by team.yaml │ ├── search_projects_handler_Empty query.yaml │ ├── search_projects_handler_Multiple results.yaml │ ├── search_projects_handler_No results.yaml │ ├── search_projects_handler_Search by query.yaml │ ├── update_comment_handler_Invalid comment identifier.yaml │ ├── update_comment_handler_Missing body.yaml │ ├── update_comment_handler_Missing comment.yaml │ ├── update_comment_handler_Valid comment update with hash only.yaml │ ├── update_comment_handler_Valid comment update with shorthand.yaml │ ├── update_comment_handler_Valid comment update.yaml │ ├── update_initiative_handler_Non-existent initiative.yaml │ ├── update_initiative_handler_Valid update.yaml │ ├── update_issue_handler_Missing id.yaml │ ├── update_issue_handler_Valid update.yaml │ ├── update_milestone_handler_Non-existent milestone.yaml │ ├── update_milestone_handler_Valid update.yaml │ ├── update_project_handler_Non-existent project.yaml │ ├── update_project_handler_Update name and description.yaml │ ├── update_project_handler_Update only description.yaml │ └── update_project_handler_Valid update.yaml └── golden ├── add_comment_handler_Missing body.golden ├── add_comment_handler_Missing issue.golden ├── add_comment_handler_Missing issueId.golden ├── add_comment_handler_Reply with shorthand.golden ├── add_comment_handler_Reply with URL.golden ├── add_comment_handler_Reply_to_comment.golden ├── add_comment_handler_Valid comment.golden ├── create_initiative_handler_Missing name.golden ├── create_initiative_handler_Valid initiative.golden ├── create_initiative_handler_With description.golden ├── create_issue_handler_Create issue with invalid project.golden ├── create_issue_handler_Create issue with labels.golden ├── create_issue_handler_Create issue with project ID.golden ├── create_issue_handler_Create issue with project name.golden ├── create_issue_handler_Create issue with project slug.golden ├── create_issue_handler_Create sub issue from identifier.golden ├── create_issue_handler_Create sub issue with labels.golden ├── create_issue_handler_Create sub issue.golden ├── create_issue_handler_Invalid team.golden ├── create_issue_handler_Missing team.golden ├── create_issue_handler_Missing teamId.golden ├── create_issue_handler_Missing title.golden ├── create_issue_handler_Valid issue with team key.golden ├── create_issue_handler_Valid issue with team name.golden ├── create_issue_handler_Valid issue with team UUID.golden ├── create_issue_handler_Valid issue with team.golden ├── create_issue_handler_Valid issue with teamId.golden ├── create_issue_handler_Valid issue.golden ├── create_milestone_handler_Invalid project ID.golden ├── create_milestone_handler_Missing name.golden ├── create_milestone_handler_Valid milestone.golden ├── create_milestone_handler_With all optional fields.golden ├── create_project_handler_Invalid team ID.golden ├── create_project_handler_Missing name.golden ├── create_project_handler_Valid project.golden ├── create_project_handler_With all optional fields.golden ├── get_initiative_handler_By name.golden ├── get_initiative_handler_Non-existent name.golden ├── get_initiative_handler_Valid initiative.golden ├── get_issue_comments_handler_Invalid issue.golden ├── get_issue_comments_handler_Missing issue.golden ├── get_issue_comments_handler_Thread_with_pagination.golden ├── get_issue_comments_handler_Valid issue.golden ├── get_issue_comments_handler_With limit.golden ├── get_issue_comments_handler_With_thread_parameter.golden ├── get_issue_handler_Get comment issue.golden ├── get_issue_handler_Missing issue.golden ├── get_issue_handler_Missing issueId.golden ├── get_issue_handler_Valid issue.golden ├── get_milestone_handler_By name.golden ├── get_milestone_handler_Non-existent milestone.golden ├── get_milestone_handler_Valid milestone.golden ├── get_project_handler_By ID.golden ├── get_project_handler_By name.golden ├── get_project_handler_By slug.golden ├── get_project_handler_Invalid project.golden ├── get_project_handler_Missing project param.golden ├── get_project_handler_Non-existent slug.golden ├── get_teams_handler_Get Teams.golden ├── get_user_issues_handler_Current user issues.golden ├── get_user_issues_handler_Specific user issues.golden ├── reply_to_comment_handler_Missing body.golden ├── reply_to_comment_handler_Missing thread.golden ├── reply_to_comment_handler_Reply with URL.golden ├── reply_to_comment_handler_Valid reply.golden ├── resource_TeamResourceHandler_Fetch By ID.golden ├── resource_TeamResourceHandler_Fetch By Key.golden ├── resource_TeamResourceHandler_Fetch By Name.golden ├── resource_TeamResourceHandler_Invalid ID.golden ├── resource_TeamResourceHandler_Missing ID.golden ├── resource_TeamsResourceHandler_List All.golden ├── search_issues_handler_Search by query.golden ├── search_issues_handler_Search by team.golden ├── search_projects_handler_Empty query.golden ├── search_projects_handler_Multiple results.golden ├── search_projects_handler_No results.golden ├── search_projects_handler_Search by query.golden ├── update_comment_handler_Invalid comment identifier.golden ├── update_comment_handler_Missing body.golden ├── update_comment_handler_Missing comment.golden ├── update_comment_handler_Valid comment update with hash only.golden ├── update_comment_handler_Valid comment update with shorthand.golden ├── update_comment_handler_Valid comment update.golden ├── update_initiative_handler_Non-existent initiative.golden ├── update_initiative_handler_Valid update.golden ├── update_issue_handler_Missing id.golden ├── update_issue_handler_Valid update.golden ├── update_milestone_handler_Non-existent milestone.golden ├── update_milestone_handler_Valid update.golden ├── update_project_handler_Non-existent project.golden ├── update_project_handler_Update name and description.golden ├── update_project_handler_Update only description.golden └── update_project_handler_Valid update.golden ``` # Files -------------------------------------------------------------------------------- /docs/prd/006-issue-comments-pagination.md: -------------------------------------------------------------------------------- ```markdown 1 | # Product Requirements Document: Issue Comments Pagination 2 | 3 | ## Overview 4 | 5 | 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. 6 | 7 | ## Background 8 | 9 | Currently, the `get_issue` tool returns all comments for an issue as part of its response. This approach has several limitations: 10 | 11 | 1. For issues with many comments, the response can become very large 12 | 2. There's no way to paginate through comments 13 | 3. There's no way to specifically retrieve replies to a particular comment 14 | 4. The client receives more data than might be needed if they're only interested in the issue details 15 | 16 | ## Requirements 17 | 18 | ### Functional Requirements 19 | 20 | 1. Create a new tool named `linear_get_issue_comments` that retrieves comments for a Linear issue 21 | 2. Implement pagination for comments to handle potentially long conversations 22 | 3. Support retrieving comments from specific threads (top-level or replies to a specific comment) 23 | 4. Modify the existing `get_issue` tool to remove the comments section and add a reference to the new tool 24 | 5. Ensure backward compatibility by maintaining the same comment formatting style 25 | 26 | ### Technical Requirements 27 | 28 | 1. The new tool should accept the following parameters: 29 | - `issue`: (required) ID or identifier of the issue to retrieve comments for 30 | - `thread`: (optional) UUID of the parent comment to retrieve replies for 31 | - `limit`: (optional) Maximum number of comments to return (default: 10) 32 | - `after`: (optional) Cursor for pagination to get comments after this point 33 | 34 | 2. The response should include: 35 | - Basic issue information (identifier, UUID) 36 | - Thread information (root or parent comment UUID) 37 | - List of comments with user, timestamp, and content 38 | - Pagination information (has more comments, next cursor) 39 | - Indication if comments have replies 40 | 41 | 3. Update the Linear client to support the new functionality: 42 | - Add a `GetIssueComments` method that supports pagination and thread filtering 43 | - Define a `GetIssueCommentsInput` struct for the parameters 44 | - Define a `PaginatedCommentConnection` struct for the response 45 | 46 | 4. Register the new tool in the server and add it to the read-only tools list 47 | 48 | ## Implementation Details 49 | 50 | ### API Changes 51 | 52 | The Linear GraphQL API already supports pagination and filtering for comments. We'll use the following query structure: 53 | 54 | ```graphql 55 | query GetIssueComments($issueId: String!, $parentId: String, $first: Int!, $after: String) { 56 | issue(id: $issueId) { 57 | comments( 58 | first: $first, 59 | after: $after, 60 | filter: { parent: { id: { eq: $parentId } } } 61 | ) { 62 | nodes { 63 | id 64 | body 65 | createdAt 66 | user { 67 | id 68 | name 69 | } 70 | childCount 71 | } 72 | pageInfo { 73 | hasNextPage 74 | endCursor 75 | } 76 | totalCount 77 | } 78 | } 79 | } 80 | ``` 81 | 82 | ### Implementation Steps 83 | 84 | | Step | Description | Status | 85 | |------|-------------|--------| 86 | | 1 | Add `GetIssueCommentsInput` and `PaginatedCommentConnection` structs to models.go | ✅ | 87 | | 2 | Implement `GetIssueComments` method in the Linear client | ✅ | 88 | | 3 | Create the `get_issue_comments.go` file with tool definition and handler | ✅ | 89 | | 4 | Update `get_issue.go` to remove comments section and add reference to new tool | ✅ | 90 | | 5 | Register the new tool in server.go | ✅ | 91 | | 6 | Add the new tool to the read-only tools list | ✅ | 92 | | 7 | Test the implementation | ✅ | 93 | 94 | ## Usage Examples 95 | 96 | ### Example 1: Get top-level comments for an issue 97 | 98 | ```json 99 | { 100 | "issue": "TEAM-123", 101 | "limit": 5 102 | } 103 | ``` 104 | 105 | ### Example 2: Get replies to a specific comment 106 | 107 | ```json 108 | { 109 | "issue": "TEAM-123", 110 | "thread": "comment-uuid-here", 111 | "limit": 10 112 | } 113 | ``` 114 | 115 | ### Example 3: Paginate through comments 116 | 117 | ```json 118 | { 119 | "issue": "TEAM-123", 120 | "after": "cursor-from-previous-response", 121 | "limit": 10 122 | } 123 | ``` 124 | 125 | ## Benefits 126 | 127 | 1. **Improved Performance**: Clients can request only the comments they need, reducing payload size 128 | 2. **Better User Experience**: Support for pagination allows handling large comment threads efficiently 129 | 3. **More Flexibility**: Ability to navigate through specific comment threads 130 | 4. **Cleaner API**: Separation of concerns between issue details and comments 131 | 132 | ## Conclusion 133 | 134 | 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. 135 | ``` -------------------------------------------------------------------------------- /pkg/server/resources_test.go: -------------------------------------------------------------------------------- ```go 1 | package server 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "path/filepath" 7 | "testing" 8 | 9 | "github.com/geropl/linear-mcp-go/pkg/linear" 10 | "github.com/google/go-cmp/cmp" 11 | "github.com/mark3labs/mcp-go/mcp" 12 | mcpserver "github.com/mark3labs/mcp-go/server" 13 | ) 14 | 15 | func TestResourceHandlers(t *testing.T) { 16 | // Define test cases 17 | tests := []struct { 18 | handlerName string 19 | name string 20 | uri string 21 | handlerFunc func(*linear.LinearClient) mcpserver.ResourceHandlerFunc 22 | }{ 23 | // TeamsResourceHandler test cases 24 | { 25 | handlerName: "TeamsResourceHandler", 26 | name: "List All", 27 | uri: "linear://teams", 28 | handlerFunc: TeamsResourceHandler, 29 | }, 30 | // TeamResourceHandler test cases 31 | { 32 | handlerName: "TeamResourceHandler", 33 | name: "Fetch By ID", 34 | uri: "linear://team/" + TEAM_ID, 35 | handlerFunc: TeamResourceHandler, 36 | }, 37 | { 38 | handlerName: "TeamResourceHandler", 39 | name: "Fetch By Name", 40 | uri: "linear://team/" + TEAM_NAME, 41 | handlerFunc: TeamResourceHandler, 42 | }, 43 | { 44 | handlerName: "TeamResourceHandler", 45 | name: "Fetch By Key", 46 | uri: "linear://team/" + TEAM_KEY, 47 | handlerFunc: TeamResourceHandler, 48 | }, 49 | { 50 | handlerName: "TeamResourceHandler", 51 | name: "Invalid ID", 52 | uri: "linear://team/invalid-identifier-does-not-exist", // Use a clearly invalid identifier 53 | handlerFunc: TeamResourceHandler, 54 | }, 55 | { 56 | handlerName: "TeamResourceHandler", 57 | name: "Missing ID", 58 | uri: "linear://team/", // Test case where ID is missing from URI path 59 | handlerFunc: TeamResourceHandler, 60 | }, 61 | } 62 | 63 | for _, tt := range tests { 64 | t.Run(tt.handlerName+"_"+tt.name, func(t *testing.T) { 65 | // Generate fixture and golden file paths 66 | fixtureName := "resource_" + tt.handlerName + "_" + tt.name 67 | goldenPath := filepath.Join("../../testdata/golden", fixtureName+".golden") 68 | 69 | // Create test client with VCR 70 | // Use distinct flags for resource tests to avoid conflicts 71 | client, cleanup := linear.NewTestClient(t, fixtureName, *record || *recordWrites) 72 | defer cleanup() 73 | 74 | // Get the handler function 75 | handler := tt.handlerFunc(client) 76 | 77 | // Create the request 78 | request := mcp.ReadResourceRequest{} 79 | request.Params.URI = tt.uri 80 | 81 | // Call the handler 82 | contents, err := handler(context.Background(), request) 83 | 84 | // Extract the actual output and error 85 | var actualOutput, actualErr string 86 | if err != nil { 87 | actualErr = err.Error() 88 | } else { 89 | // Marshal the contents to JSON for comparison 90 | jsonBytes, jsonErr := json.MarshalIndent(contents, "", " ") // Use indent for readability 91 | if jsonErr != nil { 92 | t.Fatalf("Failed to marshal resource contents to JSON: %v", jsonErr) 93 | } 94 | actualOutput = string(jsonBytes) 95 | } 96 | 97 | // If goldenResource flag is set, update the golden file 98 | if *golden { 99 | writeGoldenFile(t, goldenPath, expectation{ 100 | Err: actualErr, 101 | Output: actualOutput, 102 | }) 103 | // Also update the VCR recording implicitly by running the test 104 | t.Logf("Updated golden file: %s", goldenPath) 105 | // We might need to re-run the test without the golden flag 106 | // after recording to ensure the comparison passes. 107 | // However, for now, just writing the golden file is the goal. 108 | return // Skip comparison when updating golden files 109 | } 110 | 111 | // Otherwise, read the golden file and compare 112 | expected := readGoldenFile(t, goldenPath) 113 | 114 | // Compare error 115 | if diff := cmp.Diff(expected.Err, actualErr); diff != "" { 116 | t.Errorf("Error mismatch (-want +got):\n%s", diff) 117 | } 118 | 119 | // Compare output (only if no error is expected) 120 | if expected.Err == "" && actualErr == "" { 121 | // Compare JSON strings directly 122 | if diff := cmp.Diff(expected.Output, actualOutput); diff != "" { 123 | // To make diffs easier to read, unmarshal and compare structures 124 | var expectedContents []mcp.ResourceContents 125 | var actualContents []mcp.ResourceContents 126 | json.Unmarshal([]byte(expected.Output), &expectedContents) // Ignore error for diffing 127 | json.Unmarshal([]byte(actualOutput), &actualContents) // Ignore error for diffing 128 | t.Errorf("Output mismatch (-want +got):\n%s", cmp.Diff(expectedContents, actualContents)) 129 | t.Logf("Expected JSON:\n%s", expected.Output) 130 | t.Logf("Actual JSON:\n%s", actualOutput) 131 | } 132 | } else if expected.Err == "" && actualErr != "" { 133 | t.Errorf("Expected no error, but got: %s", actualErr) 134 | } else if expected.Err != "" && actualErr == "" { 135 | t.Errorf("Expected error '%s', but got none", expected.Err) 136 | } 137 | }) 138 | } 139 | } 140 | 141 | // readGoldenFile and writeGoldenFile are defined in test_helpers.go 142 | ``` -------------------------------------------------------------------------------- /pkg/server/resources.go: -------------------------------------------------------------------------------- ```go 1 | package server 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "strings" 8 | 9 | "github.com/geropl/linear-mcp-go/pkg/linear" 10 | "github.com/mark3labs/mcp-go/mcp" 11 | mcpserver "github.com/mark3labs/mcp-go/server" 12 | ) 13 | 14 | // TeamsResource is the resource definition for Linear teams 15 | var TeamsResource = mcp.NewResource( 16 | "linear://teams", 17 | "Linear Teams", 18 | mcp.WithResourceDescription("List of teams in Linear"), 19 | mcp.WithMIMEType("application/json"), 20 | ) 21 | 22 | // TeamResource is the resource definition for a specific Linear team 23 | var TeamResource = mcp.NewResource( 24 | "linear://team/{id}", 25 | "Linear Team", 26 | mcp.WithResourceDescription("Details of a specific team in Linear"), 27 | mcp.WithMIMEType("application/json"), 28 | ) 29 | 30 | // RegisterResources registers all Linear resources with the MCP server 31 | func RegisterResources(s *mcpserver.MCPServer, linearClient *linear.LinearClient) { 32 | // Register Teams resource 33 | s.AddResource(TeamsResource, TeamsResourceHandler(linearClient)) 34 | 35 | // Register Team resource 36 | s.AddResource(TeamResource, TeamResourceHandler(linearClient)) 37 | } 38 | 39 | // TeamsResourceHandler handles the linear://teams resource 40 | func TeamsResourceHandler(linearClient *linear.LinearClient) mcpserver.ResourceHandlerFunc { 41 | return func(ctx context.Context, request mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) { 42 | // Get teams from Linear 43 | teams, err := linearClient.GetTeams("") 44 | if err != nil { 45 | return nil, fmt.Errorf("failed to get teams: %v", err) 46 | } 47 | 48 | // Create resource content 49 | results := []mcp.ResourceContents{} 50 | for _, t := range teams { 51 | teamJSON, err := json.Marshal(t) 52 | if err != nil { 53 | return nil, fmt.Errorf("failed to marshal team: %v", err) 54 | } 55 | 56 | results = append(results, mcp.TextResourceContents{ 57 | URI: fmt.Sprintf("linear://team/%s", t.ID), 58 | MIMEType: "application/json", 59 | Text: string(teamJSON), 60 | }) 61 | } 62 | 63 | return results, nil 64 | } 65 | } 66 | 67 | // TeamResourceHandler handles the linear://team/{id} resource 68 | func TeamResourceHandler(linearClient *linear.LinearClient) mcpserver.ResourceHandlerFunc { 69 | return func(ctx context.Context, request mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) { 70 | // Extract team ID from URI 71 | uri := request.Params.URI 72 | if !strings.HasPrefix(uri, "linear://team/") { 73 | return nil, fmt.Errorf("invalid team URI: %s", uri) 74 | } 75 | 76 | teamID := uri[len("linear://team/"):] 77 | if teamID == "" { 78 | return nil, fmt.Errorf("team ID is required") 79 | } 80 | 81 | // Resolve team ID (could be UUID, name, or key) 82 | resolvedTeamID, err := resolveTeamIdentifier(linearClient, teamID) 83 | if err != nil { 84 | return nil, fmt.Errorf("failed to resolve team identifier: %v", err) 85 | } 86 | 87 | // Get all teams and find the matching one 88 | teams, err := linearClient.GetTeams("") 89 | if err != nil { 90 | return nil, fmt.Errorf("failed to get teams: %v", err) 91 | } 92 | 93 | var team *linear.Team 94 | for i, t := range teams { 95 | if t.ID == resolvedTeamID { 96 | team = &teams[i] 97 | break 98 | } 99 | } 100 | 101 | if team == nil { 102 | return nil, fmt.Errorf("team not found: %s", teamID) 103 | } 104 | 105 | // Format team as JSON 106 | teamJSON, err := json.Marshal(team) 107 | if err != nil { 108 | return nil, fmt.Errorf("failed to marshal team: %v", err) 109 | } 110 | 111 | // Create resource content 112 | return []mcp.ResourceContents{ 113 | mcp.TextResourceContents{ 114 | URI: fmt.Sprintf("linear://team/%s", team.ID), 115 | MIMEType: "application/json", 116 | Text: string(teamJSON), 117 | }, 118 | }, nil 119 | } 120 | } 121 | 122 | // resolveTeamIdentifier resolves a team identifier (UUID, name, or key) to a team ID 123 | func resolveTeamIdentifier(linearClient *linear.LinearClient, identifier string) (string, error) { 124 | // If it's a valid UUID, use it directly 125 | if isValidUUID(identifier) { 126 | return identifier, nil 127 | } 128 | 129 | // Otherwise, try to find a team by name or key 130 | teams, err := linearClient.GetTeams("") 131 | if err != nil { 132 | return "", fmt.Errorf("failed to get teams: %v", err) 133 | } 134 | 135 | // First try exact match on name or key 136 | for _, team := range teams { 137 | if team.Name == identifier || team.Key == identifier { 138 | return team.ID, nil 139 | } 140 | } 141 | 142 | // If no exact match, try case-insensitive match 143 | identifierLower := strings.ToLower(identifier) 144 | for _, team := range teams { 145 | if strings.ToLower(team.Name) == identifierLower || strings.ToLower(team.Key) == identifierLower { 146 | return team.ID, nil 147 | } 148 | } 149 | 150 | return "", fmt.Errorf("no team found with identifier '%s'", identifier) 151 | } 152 | 153 | // isValidUUID checks if a string is a valid UUID 154 | func isValidUUID(uuidStr string) bool { 155 | // Simple UUID validation - check if it has the correct format 156 | // This is a simplified version and doesn't validate the UUID fully 157 | return len(uuidStr) == 36 && strings.Count(uuidStr, "-") == 4 158 | } 159 | ``` -------------------------------------------------------------------------------- /testdata/fixtures/create_issue_handler_Valid issue with team key.yaml: -------------------------------------------------------------------------------- ```yaml 1 | --- 2 | version: 2 3 | interactions: 4 | - id: 0 5 | request: 6 | proto: HTTP/1.1 7 | proto_major: 1 8 | proto_minor: 1 9 | content_length: 310 10 | transfer_encoding: [] 11 | trailer: {} 12 | host: api.linear.app 13 | remote_addr: "" 14 | request_uri: "" 15 | body: '{"query":"\n\t\tquery GetTeams($filter: TeamFilter) {\n\t\t\tteams(filter: $filter) {\n\t\t\t\tnodes {\n\t\t\t\t\tid\n\t\t\t\t\tname\n\t\t\t\t\tkey\n\t\t\t\t\tdescription\n\t\t\t\t\tstates {\n\t\t\t\t\t\tnodes {\n\t\t\t\t\t\t\tid\n\t\t\t\t\t\t\tname\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t"}' 16 | form: {} 17 | headers: 18 | Content-Type: 19 | - application/json 20 | url: https://api.linear.app/graphql 21 | method: POST 22 | response: 23 | proto: HTTP/2.0 24 | proto_major: 2 25 | proto_minor: 0 26 | transfer_encoding: [] 27 | trailer: {} 28 | content_length: -1 29 | uncompressed: true 30 | body: | 31 | {"data":{"teams":{"nodes":[{"id":"234c5451-a839-4c8f-98d9-da00973f1060","name":"Test Team","key":"TEST","description":null,"states":{"nodes":[{"id":"d4caa373-1a02-431c-bd3f-1bbb67318617","name":"Done"},{"id":"cffb8999-f10e-447d-9672-8faf5b06ac67","name":"Todo"},{"id":"42f7ad15-fca3-4d11-b349-0e3c1385c256","name":"Backlog"},{"id":"2d26ea57-c1f7-43ae-ba30-3f828ac8edb6","name":"Canceled"},{"id":"2a939ee1-65a1-445c-8e5d-18239e5f64bc","name":"Duplicate"},{"id":"12bb7f66-d9be-4faa-800f-49b8e3b38a3f","name":"In Progress"}]}}]}}} 32 | headers: 33 | Alt-Svc: 34 | - h3=":443"; ma=86400 35 | Cache-Control: 36 | - no-store 37 | Cf-Cache-Status: 38 | - DYNAMIC 39 | Content-Type: 40 | - application/json; charset=utf-8 41 | Etag: 42 | - W/"210-+ISnhlSrm6Gd7LWWbqn3eOeSXhw" 43 | Server: 44 | - cloudflare 45 | Vary: 46 | - Accept-Encoding 47 | Via: 48 | - 1.1 google 49 | status: 200 OK 50 | code: 200 51 | duration: 0s 52 | - id: 1 53 | request: 54 | proto: HTTP/1.1 55 | proto_major: 1 56 | proto_minor: 1 57 | content_length: 845 58 | transfer_encoding: [] 59 | trailer: {} 60 | host: api.linear.app 61 | remote_addr: "" 62 | request_uri: "" 63 | 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"}}}' 64 | form: {} 65 | headers: 66 | Content-Type: 67 | - application/json 68 | url: https://api.linear.app/graphql 69 | method: POST 70 | response: 71 | proto: HTTP/2.0 72 | proto_major: 2 73 | proto_minor: 0 74 | transfer_encoding: [] 75 | trailer: {} 76 | content_length: -1 77 | uncompressed: true 78 | body: | 79 | {"data":{"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}}}} 80 | headers: 81 | Alt-Svc: 82 | - h3=":443"; ma=86400 83 | Cache-Control: 84 | - no-store 85 | Cf-Cache-Status: 86 | - DYNAMIC 87 | Content-Type: 88 | - application/json; charset=utf-8 89 | Etag: 90 | - W/"236-rqBxky5hsQ9s0ISmkxV2klYfYBg" 91 | Server: 92 | - cloudflare 93 | Vary: 94 | - Accept-Encoding 95 | Via: 96 | - 1.1 google 97 | status: 200 OK 98 | code: 200 99 | duration: 0s 100 | ``` -------------------------------------------------------------------------------- /testdata/fixtures/create_issue_handler_Valid issue with team name.yaml: -------------------------------------------------------------------------------- ```yaml 1 | --- 2 | version: 2 3 | interactions: 4 | - id: 0 5 | request: 6 | proto: HTTP/1.1 7 | proto_major: 1 8 | proto_minor: 1 9 | content_length: 310 10 | transfer_encoding: [] 11 | trailer: {} 12 | host: api.linear.app 13 | remote_addr: "" 14 | request_uri: "" 15 | body: '{"query":"\n\t\tquery GetTeams($filter: TeamFilter) {\n\t\t\tteams(filter: $filter) {\n\t\t\t\tnodes {\n\t\t\t\t\tid\n\t\t\t\t\tname\n\t\t\t\t\tkey\n\t\t\t\t\tdescription\n\t\t\t\t\tstates {\n\t\t\t\t\t\tnodes {\n\t\t\t\t\t\t\tid\n\t\t\t\t\t\t\tname\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t"}' 16 | form: {} 17 | headers: 18 | Content-Type: 19 | - application/json 20 | url: https://api.linear.app/graphql 21 | method: POST 22 | response: 23 | proto: HTTP/2.0 24 | proto_major: 2 25 | proto_minor: 0 26 | transfer_encoding: [] 27 | trailer: {} 28 | content_length: -1 29 | uncompressed: true 30 | body: | 31 | {"data":{"teams":{"nodes":[{"id":"234c5451-a839-4c8f-98d9-da00973f1060","name":"Test Team","key":"TEST","description":null,"states":{"nodes":[{"id":"d4caa373-1a02-431c-bd3f-1bbb67318617","name":"Done"},{"id":"cffb8999-f10e-447d-9672-8faf5b06ac67","name":"Todo"},{"id":"42f7ad15-fca3-4d11-b349-0e3c1385c256","name":"Backlog"},{"id":"2d26ea57-c1f7-43ae-ba30-3f828ac8edb6","name":"Canceled"},{"id":"2a939ee1-65a1-445c-8e5d-18239e5f64bc","name":"Duplicate"},{"id":"12bb7f66-d9be-4faa-800f-49b8e3b38a3f","name":"In Progress"}]}}]}}} 32 | headers: 33 | Alt-Svc: 34 | - h3=":443"; ma=86400 35 | Cache-Control: 36 | - no-store 37 | Cf-Cache-Status: 38 | - DYNAMIC 39 | Content-Type: 40 | - application/json; charset=utf-8 41 | Etag: 42 | - W/"210-+ISnhlSrm6Gd7LWWbqn3eOeSXhw" 43 | Server: 44 | - cloudflare 45 | Vary: 46 | - Accept-Encoding 47 | Via: 48 | - 1.1 google 49 | status: 200 OK 50 | code: 200 51 | duration: 0s 52 | - id: 1 53 | request: 54 | proto: HTTP/1.1 55 | proto_major: 1 56 | proto_minor: 1 57 | content_length: 846 58 | transfer_encoding: [] 59 | trailer: {} 60 | host: api.linear.app 61 | remote_addr: "" 62 | request_uri: "" 63 | 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"}}}' 64 | form: {} 65 | headers: 66 | Content-Type: 67 | - application/json 68 | url: https://api.linear.app/graphql 69 | method: POST 70 | response: 71 | proto: HTTP/2.0 72 | proto_major: 2 73 | proto_minor: 0 74 | transfer_encoding: [] 75 | trailer: {} 76 | content_length: -1 77 | uncompressed: true 78 | body: | 79 | {"data":{"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}}}} 80 | headers: 81 | Alt-Svc: 82 | - h3=":443"; ma=86400 83 | Cache-Control: 84 | - no-store 85 | Cf-Cache-Status: 86 | - DYNAMIC 87 | Content-Type: 88 | - application/json; charset=utf-8 89 | Etag: 90 | - W/"238-a41QzB6Eb98+OtI+B5Rg9E9GPa0" 91 | Server: 92 | - cloudflare 93 | Vary: 94 | - Accept-Encoding 95 | Via: 96 | - 1.1 google 97 | status: 200 OK 98 | code: 200 99 | duration: 0s 100 | ``` -------------------------------------------------------------------------------- /docs/prd/000-tool-standardization-overview.md: -------------------------------------------------------------------------------- ```markdown 1 | # Linear MCP Server Tool Standardization 2 | 3 | ## Executive Summary 4 | 5 | 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. 6 | 7 | ## Documents in this Series 8 | 9 | 1. [**000-tool-standardization-overview.md**](./000-tool-standardization-overview.md) (this document) 10 | - Executive summary and overview of the standardization effort 11 | 12 | 2. [**002-tool-standardization.md**](./002-tool-standardization.md) 13 | - Detailed requirements and rationale for the standardization rules 14 | - Analysis of current state and implementation plan 15 | 16 | 3. [**003-tool-standardization-implementation.md**](./003-tool-standardization-implementation.md) 17 | - Detailed implementation guide with specific tasks for each tool 18 | - Example implementations and code structure changes 19 | 20 | 4. [**004-tool-standardization-tracking.md**](./004-tool-standardization-tracking.md) 21 | - Tracking sheet for implementation progress 22 | - Detailed task breakdown and status tracking 23 | 24 | 5. [**005-sample-implementation.md**](./005-sample-implementation.md) 25 | - Sample code for key components 26 | - Testing examples and implementation strategy 27 | 28 | ## Standardization Rules 29 | 30 | ### Rule 1: Concise Tool Descriptions 31 | Tool descriptions should be concise and focus only on the tool's purpose and functionality, without listing parameters or explaining the result format. 32 | 33 | ### Rule 2: Flexible Object Identifier Resolution 34 | 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. 35 | 36 | ### Rule 3: Consistent Entity Rendering 37 | Tools fetching the same entities should emit results using the same format, with additional fields added at the bottom. This includes: 38 | 39 | 1. **Full Entity Rendering**: When displaying an entity as the primary subject of a response, use a consistent format with all required fields. 40 | 2. **Entity Identifier Rendering**: When referencing an entity from another entity, use a consistent, concise identifier format. 41 | 42 | ### Rule 4: Field Superset for Retrieval Methods 43 | The fields rendered on retrieval methods should follow a consistent pattern: 44 | - **Detail retrieval methods** must include all fields that can be set through create/update methods 45 | - **Overview retrieval methods** only need to include key metadata fields 46 | This ensures appropriate level of detail while maintaining consistency across the API. 47 | 48 | ## Benefits 49 | 50 | 1. **Improved User Experience** 51 | - Consistent behavior across all tools 52 | - More flexible parameter handling 53 | - Standardized output format 54 | 55 | 2. **Enhanced Code Maintainability** 56 | - Shared utility functions for common operations 57 | - Reduced code duplication 58 | - Consistent patterns across the codebase 59 | 60 | 3. **Better Quality** 61 | - Standardized error handling 62 | - Consistent validation 63 | - Comprehensive testing 64 | 65 | ## Implementation Approach 66 | 67 | The implementation will follow a phased approach: 68 | 69 | 1. **Phase 1: Create Shared Utility Functions** 70 | - Develop common identifier resolution functions 71 | - Create entity rendering functions 72 | - Establish consistent patterns 73 | 74 | 2. **Phase 2: Update Tools** 75 | - Update each tool to follow the standardization rules 76 | - Start with one tool as a reference implementation 77 | - Apply the same patterns to all remaining tools 78 | 79 | 3. **Phase 3: Update Tests** 80 | - Update test fixtures to reflect the new formatting 81 | - Add tests for the new utility functions 82 | - Verify all tests pass with the new implementation 83 | 84 | ## Timeline and Resources 85 | 86 | | Phase | Estimated Duration | Dependencies | 87 | |-------|-------------------|--------------| 88 | | Phase 1: Create Shared Utility Functions | 1 day | None | 89 | | Phase 2: Update Tools | 3 days | Phase 1 | 90 | | Phase 3: Update Tests | 1 day | Phase 2 | 91 | | **Total** | **5 days** | | 92 | 93 | ## Success Criteria 94 | 95 | The standardization effort will be considered successful when: 96 | 97 | 1. All tool descriptions are concise and focused on functionality 98 | 2. All tools that reference Linear objects accept multiple identifier types 99 | 3. All tools render entities in a consistent format 100 | 4. Retrieval methods include all fields that can be set in create/update methods 101 | 5. Code reuse is maximized through shared functions 102 | 6. All tests pass with the new implementation 103 | 104 | ## Next Steps 105 | 106 | 1. Review and approve the standardization requirements 107 | 2. Begin implementation of Phase 1 (Shared Utility Functions) 108 | 3. Select a tool to serve as the reference implementation 109 | 4. Implement changes for all tools 110 | 5. Update tests and verify functionality 111 | ``` -------------------------------------------------------------------------------- /pkg/tools/initiative_tools.go: -------------------------------------------------------------------------------- ```go 1 | package tools 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strings" 7 | 8 | "github.com/geropl/linear-mcp-go/pkg/linear" 9 | "github.com/mark3labs/mcp-go/mcp" 10 | ) 11 | 12 | var GetInitiativeTool = mcp.NewTool("linear_get_initiative", 13 | mcp.WithDescription("Get a single initiative by its identifier (ID or name)."), 14 | mcp.WithString("initiative", mcp.Required(), mcp.Description("The identifier of the initiative to get. Can be the initiative's ID or name.")), 15 | ) 16 | 17 | func GetInitiativeHandler(linearClient *linear.LinearClient) func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { 18 | return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { 19 | initiativeIdentifier, err := request.RequireString("initiative") 20 | if err != nil { 21 | return &mcp.CallToolResult{IsError: true, Content: []mcp.Content{mcp.TextContent{Type: "text", Text: err.Error()}}}, nil 22 | } 23 | 24 | initiative, err := linearClient.GetInitiative(initiativeIdentifier) 25 | if err != nil { 26 | return &mcp.CallToolResult{IsError: true, Content: []mcp.Content{mcp.TextContent{Type: "text", Text: fmt.Sprintf("Failed to get initiative: %v", err)}}}, nil 27 | } 28 | 29 | resultText := FormatInitiative(*initiative) 30 | 31 | return &mcp.CallToolResult{Content: []mcp.Content{mcp.TextContent{Type: "text", Text: resultText}}}, nil 32 | } 33 | } 34 | 35 | func FormatInitiative(initiative linear.Initiative) string { 36 | var builder strings.Builder 37 | builder.WriteString(fmt.Sprintf("Initiative: %s\n", initiative.Name)) 38 | builder.WriteString(fmt.Sprintf(" ID: %s\n", initiative.ID)) 39 | if initiative.Description != "" { 40 | builder.WriteString(fmt.Sprintf(" Description: %s\n", initiative.Description)) 41 | } 42 | builder.WriteString(fmt.Sprintf(" URL: %s\n", initiative.URL)) 43 | return builder.String() 44 | } 45 | 46 | var CreateInitiativeTool = mcp.NewTool("linear_create_initiative", 47 | mcp.WithDescription("Create a new initiative."), 48 | mcp.WithString("name", mcp.Required(), mcp.Description("The name of the initiative.")), 49 | mcp.WithString("description", mcp.Description("The description of the initiative.")), 50 | ) 51 | 52 | func CreateInitiativeHandler(linearClient *linear.LinearClient) func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { 53 | return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { 54 | name, err := request.RequireString("name") 55 | if err != nil { 56 | return &mcp.CallToolResult{IsError: true, Content: []mcp.Content{mcp.TextContent{Type: "text", Text: err.Error()}}}, nil 57 | } 58 | 59 | description := request.GetString("description", "") 60 | 61 | input := linear.InitiativeCreateInput{ 62 | Name: name, 63 | Description: description, 64 | } 65 | 66 | initiative, err := linearClient.CreateInitiative(input) 67 | if err != nil { 68 | return &mcp.CallToolResult{IsError: true, Content: []mcp.Content{mcp.TextContent{Type: "text", Text: fmt.Sprintf("Failed to create initiative: %v", err)}}}, nil 69 | } 70 | 71 | resultText := FormatInitiative(*initiative) 72 | 73 | return &mcp.CallToolResult{Content: []mcp.Content{mcp.TextContent{Type: "text", Text: resultText}}}, nil 74 | } 75 | } 76 | 77 | var UpdateInitiativeTool = mcp.NewTool("linear_update_initiative", 78 | mcp.WithDescription("Update an existing initiative."), 79 | mcp.WithString("initiative", mcp.Required(), mcp.Description("The ID or name of the initiative to update.")), 80 | mcp.WithString("name", mcp.Description("The new name of the initiative.")), 81 | mcp.WithString("description", mcp.Description("The new description of the initiative.")), 82 | ) 83 | 84 | func UpdateInitiativeHandler(linearClient *linear.LinearClient) func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { 85 | return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { 86 | initiativeIdentifier, err := request.RequireString("initiative") 87 | if err != nil { 88 | return &mcp.CallToolResult{IsError: true, Content: []mcp.Content{mcp.TextContent{Type: "text", Text: err.Error()}}}, nil 89 | } 90 | 91 | // Get the initiative first to get its ID 92 | init, err := linearClient.GetInitiative(initiativeIdentifier) 93 | if err != nil { 94 | return &mcp.CallToolResult{IsError: true, Content: []mcp.Content{mcp.TextContent{Type: "text", Text: fmt.Sprintf("Failed to get initiative: %v", err)}}}, nil 95 | } 96 | 97 | name := request.GetString("name", "") 98 | description := request.GetString("description", "") 99 | 100 | input := linear.InitiativeUpdateInput{ 101 | Name: name, 102 | Description: description, 103 | } 104 | 105 | initiative, err := linearClient.UpdateInitiative(init.ID, input) 106 | if err != nil { 107 | return &mcp.CallToolResult{IsError: true, Content: []mcp.Content{mcp.TextContent{Type: "text", Text: fmt.Sprintf("Failed to update initiative: %v", err)}}}, nil 108 | } 109 | 110 | resultText := FormatInitiative(*initiative) 111 | 112 | return &mcp.CallToolResult{Content: []mcp.Content{mcp.TextContent{Type: "text", Text: resultText}}}, nil 113 | } 114 | } 115 | ``` -------------------------------------------------------------------------------- /pkg/tools/rendering.go: -------------------------------------------------------------------------------- ```go 1 | package tools 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/geropl/linear-mcp-go/pkg/linear" 8 | ) 9 | 10 | // Full Entity Rendering Functions 11 | 12 | // formatIssue returns a consistently formatted full representation of an issue 13 | func formatIssue(issue *linear.Issue) string { 14 | if issue == nil { 15 | return "Issue: Unknown" 16 | } 17 | 18 | var result strings.Builder 19 | result.WriteString(fmt.Sprintf("Issue: %s (UUID: %s)\n", issue.Identifier, issue.ID)) 20 | result.WriteString(fmt.Sprintf("Title: %s\n", issue.Title)) 21 | result.WriteString(fmt.Sprintf("URL: %s\n", issue.URL)) 22 | 23 | result.WriteString(fmt.Sprintf("Priority: %s\n", priorityToString(issue.Priority))) 24 | 25 | statusStr := "None" 26 | if issue.Status != "" { 27 | statusStr = issue.Status 28 | } else if issue.State != nil { 29 | statusStr = issue.State.Name 30 | } 31 | result.WriteString(fmt.Sprintf("Status: %s\n", statusStr)) 32 | 33 | // Include labels if available 34 | if issue.Labels != nil && len(issue.Labels.Nodes) > 0 { 35 | labelNames := make([]string, 0, len(issue.Labels.Nodes)) 36 | for _, label := range issue.Labels.Nodes { 37 | labelNames = append(labelNames, label.Name) 38 | } 39 | result.WriteString(fmt.Sprintf("Labels: %s\n", strings.Join(labelNames, ", "))) 40 | } else { 41 | result.WriteString("Labels: None\n") 42 | } 43 | 44 | // Include description 45 | if issue.Description != "" { 46 | result.WriteString(fmt.Sprintf("Description: %s\n", issue.Description)) 47 | } else { 48 | result.WriteString("Description: None\n") 49 | } 50 | 51 | return result.String() 52 | } 53 | 54 | // formatTeam returns a consistently formatted full representation of a team 55 | func formatTeam(team *linear.Team) string { 56 | if team == nil { 57 | return "Team: Unknown" 58 | } 59 | 60 | var result strings.Builder 61 | result.WriteString(fmt.Sprintf("Team: %s (UUID: %s)\n", team.Name, team.ID)) 62 | result.WriteString(fmt.Sprintf("Key: %s\n", team.Key)) 63 | 64 | return result.String() 65 | } 66 | 67 | // formatUser returns a consistently formatted full representation of a user 68 | func formatUser(user *linear.User) string { 69 | if user == nil { 70 | return "User: Unknown" 71 | } 72 | 73 | var result strings.Builder 74 | result.WriteString(fmt.Sprintf("User: %s (UUID: %s)\n", user.Name, user.ID)) 75 | 76 | if user.Email != "" { 77 | result.WriteString(fmt.Sprintf("Email: %s\n", user.Email)) 78 | } 79 | 80 | return result.String() 81 | } 82 | 83 | // formatComment returns a consistently formatted full representation of a comment 84 | func formatComment(comment *linear.Comment) string { 85 | if comment == nil { 86 | return "Comment: Unknown" 87 | } 88 | 89 | userName := "Unknown" 90 | if comment.User != nil { 91 | userName = comment.User.Name 92 | } 93 | 94 | var result strings.Builder 95 | result.WriteString(fmt.Sprintf("Comment by %s (UUID: %s)\n", userName, comment.ID)) 96 | result.WriteString(fmt.Sprintf("Body: %s\n", comment.Body)) 97 | 98 | if !comment.CreatedAt.IsZero() { 99 | result.WriteString(fmt.Sprintf("Created: %s\n", comment.CreatedAt.Format("2006-01-02 15:04:05"))) 100 | } 101 | 102 | return result.String() 103 | } 104 | 105 | // Entity Identifier Rendering Functions 106 | 107 | // formatIssueIdentifier returns a consistently formatted identifier for an issue 108 | func formatIssueIdentifier(issue *linear.Issue) string { 109 | if issue == nil { 110 | return "Issue: Unknown" 111 | } 112 | return fmt.Sprintf("Issue: %s (UUID: %s)", issue.Identifier, issue.ID) 113 | } 114 | 115 | // formatTeamIdentifier returns a consistently formatted identifier for a team 116 | func formatTeamIdentifier(team *linear.Team) string { 117 | if team == nil { 118 | return "Team: Unknown" 119 | } 120 | return fmt.Sprintf("Team: %s (UUID: %s)", team.Name, team.ID) 121 | } 122 | 123 | // formatUserIdentifier returns a consistently formatted identifier for a user 124 | func formatUserIdentifier(user *linear.User) string { 125 | if user == nil { 126 | return "User: Unknown" 127 | } 128 | return fmt.Sprintf("User: %s (UUID: %s)", user.Name, user.ID) 129 | } 130 | 131 | // formatCommentIdentifier returns a consistently formatted identifier for a comment 132 | func formatCommentIdentifier(comment *linear.Comment) string { 133 | if comment == nil { 134 | return "Unknown" 135 | } 136 | 137 | return fmt.Sprintf(comment.ID) 138 | } 139 | 140 | // formatNewComment returns a consistently formatted response for a newly created comment 141 | // parentID should be the UUID of the parent comment if this is a reply, empty string otherwise 142 | func formatNewComment(comment *linear.Comment, issue *linear.Issue, parentID string) string { 143 | if comment == nil || issue == nil { 144 | return "Error: Invalid comment or issue" 145 | } 146 | 147 | var result strings.Builder 148 | 149 | // Format based on whether this is a reply or top-level comment 150 | if parentID != "" { 151 | // This is a reply 152 | result.WriteString(fmt.Sprintf("Replied with Comment: %s\n", comment.ID)) 153 | result.WriteString(fmt.Sprintf("to Thread: %s\n", parentID)) 154 | result.WriteString(fmt.Sprintf("on %s\n", formatIssueIdentifier(issue))) 155 | } else { 156 | // This is a top-level comment 157 | result.WriteString(fmt.Sprintf("Added Comment: %s\n", comment.ID)) 158 | result.WriteString(fmt.Sprintf("to %s\n", formatIssueIdentifier(issue))) 159 | } 160 | 161 | result.WriteString(fmt.Sprintf("URL: %s", comment.URL)) 162 | 163 | return result.String() 164 | } 165 | ``` -------------------------------------------------------------------------------- /pkg/tools/search_issues.go: -------------------------------------------------------------------------------- ```go 1 | package tools 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strings" 7 | 8 | "github.com/geropl/linear-mcp-go/pkg/linear" 9 | "github.com/mark3labs/mcp-go/mcp" 10 | ) 11 | 12 | // SearchIssuesTool is the tool definition for searching issues 13 | var SearchIssuesTool = mcp.NewTool("linear_search_issues", 14 | mcp.WithDescription("Searches Linear issues."), 15 | mcp.WithString("query", mcp.Description("Optional text to search in title and description")), 16 | mcp.WithString("team", mcp.Description("Filter by team identifier (UUID, name, or key)")), 17 | mcp.WithString("status", mcp.Description("Filter by status name (e.g., 'In Progress', 'Done')")), 18 | mcp.WithString("assignee", mcp.Description("Filter by assignee identifier (UUID, name, or email)")), 19 | mcp.WithString("labels", mcp.Description("Filter by label names (comma-separated)")), 20 | mcp.WithString("priority", getPriorityOptions()...), 21 | mcp.WithNumber("estimate", mcp.Description("Filter by estimate points")), 22 | mcp.WithBoolean("includeArchived", mcp.Description("Include archived issues in results (default: false)")), 23 | mcp.WithNumber("limit", mcp.Description("Max results to return (default: 10)")), 24 | ) 25 | 26 | // SearchIssuesHandler handles the linear_search_issues tool 27 | func SearchIssuesHandler(linearClient *linear.LinearClient) func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { 28 | return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { 29 | // Build search input 30 | input := linear.SearchIssuesInput{} 31 | 32 | input.Query = request.GetString("query", "") 33 | 34 | if team, err := request.RequireString("team"); err == nil && team != "" { 35 | // Resolve team identifier to a team ID 36 | teamID, err := resolveTeamIdentifier(linearClient, team) 37 | if err != nil { 38 | return &mcp.CallToolResult{IsError: true, Content: []mcp.Content{mcp.TextContent{Type: "text", Text: fmt.Sprintf("Failed to resolve team: %v", err)}}}, nil 39 | } 40 | input.TeamID = teamID 41 | } 42 | 43 | input.Status = request.GetString("status", "") 44 | 45 | if assignee, err := request.RequireString("assignee"); err == nil && assignee != "" { 46 | // Resolve assignee identifier to a user ID 47 | assigneeID, err := resolveUserIdentifier(linearClient, assignee) 48 | if err != nil { 49 | return &mcp.CallToolResult{IsError: true, Content: []mcp.Content{mcp.TextContent{Type: "text", Text: fmt.Sprintf("Failed to resolve assignee: %v", err)}}}, nil 50 | } 51 | input.AssigneeID = assigneeID 52 | } 53 | 54 | if labelsStr, err := request.RequireString("labels"); err == nil && labelsStr != "" { 55 | // Split comma-separated labels 56 | labels := []string{} 57 | for _, label := range strings.Split(labelsStr, ",") { 58 | trimmedLabel := strings.TrimSpace(label) 59 | if trimmedLabel != "" { 60 | labels = append(labels, trimmedLabel) 61 | } 62 | } 63 | input.Labels = labels 64 | } 65 | 66 | if priorityStr, err := request.RequireString("priority"); err == nil && priorityStr != "" { 67 | priority, err := parsePriority(priorityStr) 68 | if err != nil { 69 | return &mcp.CallToolResult{IsError: true, Content: []mcp.Content{mcp.TextContent{Type: "text", Text: fmt.Sprintf("Invalid priority: %v", err)}}}, nil 70 | } 71 | input.Priority = &priority 72 | } 73 | 74 | if estimate, err := request.RequireFloat("estimate"); err == nil { 75 | input.Estimate = &estimate 76 | } 77 | 78 | input.IncludeArchived = request.GetBool("includeArchived", false) 79 | input.Limit = request.GetInt("limit", 10) 80 | 81 | // Search for issues 82 | issues, err := linearClient.SearchIssues(input) 83 | if err != nil { 84 | return &mcp.CallToolResult{IsError: true, Content: []mcp.Content{mcp.TextContent{Type: "text", Text: fmt.Sprintf("Failed to search issues: %v", err)}}}, nil 85 | } 86 | 87 | // Format the result 88 | resultText := fmt.Sprintf("Found %d issues:\n", len(issues)) 89 | for _, issue := range issues { 90 | // Create a temporary Issue object to use with formatIssueIdentifier 91 | tempIssue := &linear.Issue{ 92 | ID: issue.ID, 93 | Identifier: issue.Identifier, 94 | } 95 | 96 | priorityStr := priorityToString(issue.Priority) 97 | 98 | statusStr := "None" 99 | if issue.Status != "" { 100 | statusStr = issue.Status 101 | } else if issue.StateName != "" { 102 | statusStr = issue.StateName 103 | } 104 | 105 | resultText += fmt.Sprintf("- %s\n", formatIssueIdentifier(tempIssue)) 106 | resultText += fmt.Sprintf(" Title: %s\n", issue.Title) 107 | resultText += fmt.Sprintf(" Priority: %s\n", priorityStr) 108 | resultText += fmt.Sprintf(" Status: %s\n", statusStr) 109 | if issue.Project != nil { 110 | resultText += fmt.Sprintf(" Project: %s (%s)\n", issue.Project.Name, issue.Project.ID) 111 | } else { 112 | resultText += " Project: None\n" 113 | } 114 | if issue.ProjectMilestone != nil { 115 | resultText += fmt.Sprintf(" Milestone: %s (%s)\n", issue.ProjectMilestone.Name, issue.ProjectMilestone.ID) 116 | } else { 117 | resultText += " Milestone: None\n" 118 | } 119 | resultText += fmt.Sprintf(" URL: %s\n", issue.URL) 120 | } 121 | 122 | return &mcp.CallToolResult{Content: []mcp.Content{mcp.TextContent{Type: "text", Text: resultText}}}, nil 123 | } 124 | } 125 | ``` -------------------------------------------------------------------------------- /testdata/fixtures/get_issue_handler_Valid issue.yaml: -------------------------------------------------------------------------------- ```yaml 1 | --- 2 | version: 2 3 | interactions: 4 | - id: 0 5 | request: 6 | proto: HTTP/1.1 7 | proto_major: 1 8 | proto_minor: 1 9 | content_length: 322 10 | transfer_encoding: [] 11 | trailer: {} 12 | host: api.linear.app 13 | remote_addr: "" 14 | request_uri: "" 15 | body: '{"query":"\n\t\tquery GetIssueByIdentifier($teamKey: String!, $number: Float!) {\n\t\t\tissues(filter: { team: { key: { eq: $teamKey } }, number: { eq: $number } }, first: 1) {\n\t\t\t\tnodes {\n\t\t\t\t\tid\n\t\t\t\t\tidentifier\n\t\t\t\t\ttitle\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t","variables":{"number":10,"teamKey":"TEST"}}' 16 | form: {} 17 | headers: 18 | Content-Type: 19 | - application/json 20 | url: https://api.linear.app/graphql 21 | method: POST 22 | response: 23 | proto: HTTP/2.0 24 | proto_major: 2 25 | proto_minor: 0 26 | transfer_encoding: [] 27 | trailer: {} 28 | content_length: -1 29 | uncompressed: true 30 | body: | 31 | {"data":{"issues":{"nodes":[{"id":"1c2de93f-4321-4015-bfde-ee893ef7976f","identifier":"TEST-10","title":"Updated Test Issue"}]}}} 32 | headers: 33 | Alt-Svc: 34 | - h3=":443"; ma=86400 35 | Cache-Control: 36 | - no-store 37 | Cf-Cache-Status: 38 | - DYNAMIC 39 | Content-Type: 40 | - application/json; charset=utf-8 41 | Etag: 42 | - W/"82-w0K/VnjlqJtYAurPyBwU/9QgAFo" 43 | Server: 44 | - cloudflare 45 | Vary: 46 | - Accept-Encoding 47 | Via: 48 | - 1.1 google 49 | status: 200 OK 50 | code: 200 51 | duration: 0s 52 | - id: 1 53 | request: 54 | proto: HTTP/1.1 55 | proto_major: 1 56 | proto_minor: 1 57 | content_length: 1316 58 | transfer_encoding: [] 59 | trailer: {} 60 | host: api.linear.app 61 | remote_addr: "" 62 | request_uri: "" 63 | 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"}}' 64 | form: {} 65 | headers: 66 | Content-Type: 67 | - application/json 68 | url: https://api.linear.app/graphql 69 | method: POST 70 | response: 71 | proto: HTTP/2.0 72 | proto_major: 2 73 | proto_minor: 0 74 | transfer_encoding: [] 75 | trailer: {} 76 | content_length: -1 77 | uncompressed: true 78 | body: | 79 | {"data":{"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":[]}}}} 80 | headers: 81 | Alt-Svc: 82 | - h3=":443"; ma=86400 83 | Cache-Control: 84 | - no-store 85 | Cf-Cache-Status: 86 | - DYNAMIC 87 | Content-Type: 88 | - application/json; charset=utf-8 89 | Etag: 90 | - W/"36b-tillFLIUMm8VXol85JbmMotLYUg" 91 | Server: 92 | - cloudflare 93 | Vary: 94 | - Accept-Encoding 95 | Via: 96 | - 1.1 google 97 | status: 200 OK 98 | code: 200 99 | duration: 0s 100 | ``` -------------------------------------------------------------------------------- /testdata/fixtures/get_issue_handler_Get comment issue.yaml: -------------------------------------------------------------------------------- ```yaml 1 | --- 2 | version: 2 3 | interactions: 4 | - id: 0 5 | request: 6 | proto: HTTP/1.1 7 | proto_major: 1 8 | proto_minor: 1 9 | content_length: 322 10 | transfer_encoding: [] 11 | trailer: {} 12 | host: api.linear.app 13 | remote_addr: "" 14 | request_uri: "" 15 | body: '{"query":"\n\t\tquery GetIssueByIdentifier($teamKey: String!, $number: Float!) {\n\t\t\tissues(filter: { team: { key: { eq: $teamKey } }, number: { eq: $number } }, first: 1) {\n\t\t\t\tnodes {\n\t\t\t\t\tid\n\t\t\t\t\tidentifier\n\t\t\t\t\ttitle\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t","variables":{"number":12,"teamKey":"TEST"}}' 16 | form: {} 17 | headers: 18 | Content-Type: 19 | - application/json 20 | url: https://api.linear.app/graphql 21 | method: POST 22 | response: 23 | proto: HTTP/2.0 24 | proto_major: 2 25 | proto_minor: 0 26 | transfer_encoding: [] 27 | trailer: {} 28 | content_length: -1 29 | uncompressed: true 30 | body: | 31 | {"data":{"issues":{"nodes":[{"id":"9407c793-5fd8-4730-9280-0e17ffddf320","identifier":"TEST-12","title":"Comments issue"}]}}} 32 | headers: 33 | Alt-Svc: 34 | - h3=":443"; ma=86400 35 | Cache-Control: 36 | - no-store 37 | Cf-Cache-Status: 38 | - DYNAMIC 39 | Content-Type: 40 | - application/json; charset=utf-8 41 | Etag: 42 | - W/"7e-a2LOPkL8PZhOQop7X2YpU+ZF/Y8" 43 | Server: 44 | - cloudflare 45 | Vary: 46 | - Accept-Encoding 47 | Via: 48 | - 1.1 google 49 | status: 200 OK 50 | code: 200 51 | duration: 0s 52 | - id: 1 53 | request: 54 | proto: HTTP/1.1 55 | proto_major: 1 56 | proto_minor: 1 57 | content_length: 1316 58 | transfer_encoding: [] 59 | trailer: {} 60 | host: api.linear.app 61 | remote_addr: "" 62 | request_uri: "" 63 | 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"}}' 64 | form: {} 65 | headers: 66 | Content-Type: 67 | - application/json 68 | url: https://api.linear.app/graphql 69 | method: POST 70 | response: 71 | proto: HTTP/2.0 72 | proto_major: 2 73 | proto_minor: 0 74 | transfer_encoding: [] 75 | trailer: {} 76 | content_length: -1 77 | uncompressed: true 78 | body: | 79 | {"data":{"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"}]}}}} 80 | headers: 81 | Alt-Svc: 82 | - h3=":443"; ma=86400 83 | Cache-Control: 84 | - no-store 85 | Cf-Cache-Status: 86 | - DYNAMIC 87 | Content-Type: 88 | - application/json; charset=utf-8 89 | Etag: 90 | - W/"3d2-y7Op6fHSC2Lvc4f+0aw4k03LArM" 91 | Server: 92 | - cloudflare 93 | Vary: 94 | - Accept-Encoding 95 | Via: 96 | - 1.1 google 97 | status: 200 OK 98 | code: 200 99 | duration: 0s 100 | ``` -------------------------------------------------------------------------------- /testdata/fixtures/get_project_handler_By name.yaml: -------------------------------------------------------------------------------- ```yaml 1 | --- 2 | version: 2 3 | interactions: 4 | - id: 0 5 | request: 6 | proto: HTTP/1.1 7 | proto_major: 1 8 | proto_minor: 1 9 | content_length: 719 10 | transfer_encoding: [] 11 | trailer: {} 12 | host: api.linear.app 13 | remote_addr: "" 14 | request_uri: "" 15 | body: '{"query":"\n\t\tquery GetProject($id: String!) {\n\t\t\tproject(id: $id) {\n\t\t\t\tid\n\t\t\t\tname\n\t\t\t\tdescription\n\t\t\t\tslugId\n\t\t\t\tstate\n\t\t\t\turl\n\t\t\t\tcreatedAt\n\t\t\t\tupdatedAt\n\t\t\t\tlead {\n\t\t\t\t\tid\n\t\t\t\t\tname\n\t\t\t\t\temail\n\t\t\t\t}\n\t\t\t\tmembers {\n\t\t\t\t\tnodes {\n\t\t\t\t\t\tid\n\t\t\t\t\t\tname\n\t\t\t\t\t\temail\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tteams {\n\t\t\t\t\tnodes {\n\t\t\t\t\t\tid\n\t\t\t\t\t\tname\n\t\t\t\t\t\tkey\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tinitiatives(first: 10) {\n\t\t\t\t\tnodes {\n\t\t\t\t\t\tid\n\t\t\t\t\t\tname\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tstartDate\n\t\t\t\ttargetDate\n\t\t\t}\n\t\t}\n\t","variables":{"id":"MCP tool investigation"}}' 16 | form: {} 17 | headers: 18 | Content-Type: 19 | - application/json 20 | url: https://api.linear.app/graphql 21 | method: POST 22 | response: 23 | proto: HTTP/2.0 24 | proto_major: 2 25 | proto_minor: 0 26 | transfer_encoding: [] 27 | trailer: {} 28 | content_length: -1 29 | uncompressed: true 30 | body: | 31 | {"errors":[{"message":"Entity not found: 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} 32 | headers: 33 | Alt-Svc: 34 | - h3=":443"; ma=86400 35 | Cache-Control: 36 | - no-store 37 | Cf-Cache-Status: 38 | - DYNAMIC 39 | Content-Type: 40 | - application/json; charset=utf-8 41 | Etag: 42 | - W/"113-pUQ9mkDn3KWYiQz0UBE51+d7gJ4" 43 | Server: 44 | - cloudflare 45 | Vary: 46 | - Accept-Encoding 47 | Via: 48 | - 1.1 google 49 | status: 200 OK 50 | code: 200 51 | duration: 0s 52 | - id: 1 53 | request: 54 | proto: HTTP/1.1 55 | proto_major: 1 56 | proto_minor: 1 57 | content_length: 907 58 | transfer_encoding: [] 59 | trailer: {} 60 | host: api.linear.app 61 | remote_addr: "" 62 | request_uri: "" 63 | 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":""}}]}}}' 64 | form: {} 65 | headers: 66 | Content-Type: 67 | - application/json 68 | url: https://api.linear.app/graphql 69 | method: POST 70 | response: 71 | proto: HTTP/2.0 72 | proto_major: 2 73 | proto_minor: 0 74 | transfer_encoding: [] 75 | trailer: {} 76 | content_length: -1 77 | uncompressed: true 78 | body: | 79 | {"data":{"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"}]}}} 80 | headers: 81 | Alt-Svc: 82 | - h3=":443"; ma=86400 83 | Cache-Control: 84 | - no-store 85 | Cf-Cache-Status: 86 | - DYNAMIC 87 | Content-Type: 88 | - application/json; charset=utf-8 89 | Etag: 90 | - W/"355-Jji1j11utIAgJU/7ATKvhRyba4g" 91 | Server: 92 | - cloudflare 93 | Vary: 94 | - Accept-Encoding 95 | Via: 96 | - 1.1 google 97 | status: 200 OK 98 | code: 200 99 | duration: 0s 100 | ``` -------------------------------------------------------------------------------- /pkg/tools/create_issue.go: -------------------------------------------------------------------------------- ```go 1 | package tools 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strings" 7 | 8 | "github.com/geropl/linear-mcp-go/pkg/linear" 9 | "github.com/mark3labs/mcp-go/mcp" 10 | ) 11 | 12 | // CreateIssueTool is the tool definition for creating issues 13 | var CreateIssueTool = mcp.NewTool("linear_create_issue", 14 | mcp.WithDescription("Creates a new Linear issue."), 15 | mcp.WithString("title", mcp.Required(), mcp.Description("Issue title")), 16 | mcp.WithString("team", mcp.Required(), mcp.Description("Team identifier (key, UUID or name)")), 17 | mcp.WithString("description", mcp.Description("Issue description")), 18 | mcp.WithString("priority", getPriorityOptions()...), 19 | mcp.WithString("status", mcp.Description("Issue status")), 20 | 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.")), 21 | mcp.WithString("labels", mcp.Description("Optional comma-separated list of label IDs or names to assign")), 22 | mcp.WithString("project", mcp.Description("Optional project identifier (ID, name, or slug) to assign the issue to")), 23 | ) 24 | 25 | // CreateIssueHandler handles the linear_create_issue tool 26 | func CreateIssueHandler(linearClient *linear.LinearClient) func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { 27 | return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { 28 | // Extract arguments 29 | title, err := request.RequireString("title") 30 | if err != nil { 31 | return &mcp.CallToolResult{IsError: true, Content: []mcp.Content{mcp.TextContent{Type: "text", Text: err.Error()}}}, nil 32 | } 33 | 34 | team, err := request.RequireString("team") 35 | if err != nil { 36 | return &mcp.CallToolResult{IsError: true, Content: []mcp.Content{mcp.TextContent{Type: "text", Text: err.Error()}}}, nil 37 | } 38 | 39 | // Resolve team identifier to a team ID 40 | teamId, err := resolveTeamIdentifier(linearClient, team) 41 | if err != nil { 42 | return &mcp.CallToolResult{IsError: true, Content: []mcp.Content{mcp.TextContent{Type: "text", Text: fmt.Sprintf("Failed to resolve team: %v", err)}}}, nil 43 | } 44 | 45 | // Extract optional arguments 46 | description := request.GetString("description", "") 47 | 48 | var priority *int 49 | if priorityStr, err := request.RequireString("priority"); err == nil && priorityStr != "" { 50 | p, err := parsePriority(priorityStr) 51 | if err != nil { 52 | return &mcp.CallToolResult{IsError: true, Content: []mcp.Content{mcp.TextContent{Type: "text", Text: fmt.Sprintf("Invalid priority: %v", err)}}}, nil 53 | } 54 | priority = &p 55 | } 56 | 57 | status := request.GetString("status", "") 58 | 59 | // Extract makeSubissueOf parameter and resolve it if needed 60 | var parentID *string 61 | if parentIssue, err := request.RequireString("makeSubissueOf"); err == nil && parentIssue != "" { 62 | resolvedParentID, err := resolveIssueIdentifier(linearClient, parentIssue) 63 | if err != nil { 64 | return &mcp.CallToolResult{IsError: true, Content: []mcp.Content{mcp.TextContent{Type: "text", Text: fmt.Sprintf("Failed to resolve parent issue: %v", err)}}}, nil 65 | } 66 | parentID = &resolvedParentID 67 | } 68 | 69 | // Extract labels parameter and resolve them if needed 70 | var labelIDs []string 71 | if labelsStr, err := request.RequireString("labels"); err == nil && labelsStr != "" { 72 | // Split comma-separated labels 73 | var labelIdentifiers []string 74 | for _, label := range strings.Split(labelsStr, ",") { 75 | trimmedLabel := strings.TrimSpace(label) 76 | if trimmedLabel != "" { 77 | labelIdentifiers = append(labelIdentifiers, trimmedLabel) 78 | } 79 | } 80 | 81 | // Resolve label identifiers to UUIDs 82 | if len(labelIdentifiers) > 0 { 83 | resolvedLabelIDs, err := resolveLabelIdentifiers(linearClient, teamId, labelIdentifiers) 84 | if err != nil { 85 | return &mcp.CallToolResult{IsError: true, Content: []mcp.Content{mcp.TextContent{Type: "text", Text: fmt.Sprintf("Failed to resolve labels: %v", err)}}}, nil 86 | } 87 | labelIDs = resolvedLabelIDs 88 | } 89 | } 90 | 91 | // Extract project parameter and resolve it if needed 92 | var projectID string 93 | if project, err := request.RequireString("project"); err == nil && project != "" { 94 | resolvedProjectID, err := resolveProjectIdentifier(linearClient, project) 95 | if err != nil { 96 | return &mcp.CallToolResult{IsError: true, Content: []mcp.Content{mcp.TextContent{Type: "text", Text: fmt.Sprintf("Failed to resolve project: %v", err)}}}, nil 97 | } 98 | projectID = resolvedProjectID 99 | } 100 | 101 | // Create the issue 102 | input := linear.CreateIssueInput{ 103 | Title: title, 104 | TeamID: teamId, 105 | Description: description, 106 | Priority: priority, 107 | Status: status, 108 | ParentID: parentID, 109 | LabelIDs: labelIDs, 110 | ProjectID: projectID, 111 | } 112 | 113 | issue, err := linearClient.CreateIssue(input) 114 | if err != nil { 115 | return &mcp.CallToolResult{IsError: true, Content: []mcp.Content{mcp.TextContent{Type: "text", Text: fmt.Sprintf("Failed to create issue: %v", err)}}}, nil 116 | } 117 | 118 | // Return the result 119 | resultText := fmt.Sprintf("Created %s", formatIssueIdentifier(issue)) 120 | resultText += fmt.Sprintf("\nTitle: %s", issue.Title) 121 | resultText += fmt.Sprintf("\nURL: %s", issue.URL) 122 | return &mcp.CallToolResult{Content: []mcp.Content{mcp.TextContent{Type: "text", Text: resultText}}}, nil 123 | } 124 | } 125 | ``` -------------------------------------------------------------------------------- /testdata/fixtures/get_project_handler_By slug.yaml: -------------------------------------------------------------------------------- ```yaml 1 | --- 2 | version: 2 3 | interactions: 4 | - id: 0 5 | request: 6 | proto: HTTP/1.1 7 | proto_major: 1 8 | proto_minor: 1 9 | content_length: 732 10 | transfer_encoding: [] 11 | trailer: {} 12 | host: api.linear.app 13 | remote_addr: "" 14 | request_uri: "" 15 | body: '{"query":"\n\t\tquery GetProject($id: String!) {\n\t\t\tproject(id: $id) {\n\t\t\t\tid\n\t\t\t\tname\n\t\t\t\tdescription\n\t\t\t\tslugId\n\t\t\t\tstate\n\t\t\t\turl\n\t\t\t\tcreatedAt\n\t\t\t\tupdatedAt\n\t\t\t\tlead {\n\t\t\t\t\tid\n\t\t\t\t\tname\n\t\t\t\t\temail\n\t\t\t\t}\n\t\t\t\tmembers {\n\t\t\t\t\tnodes {\n\t\t\t\t\t\tid\n\t\t\t\t\t\tname\n\t\t\t\t\t\temail\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tteams {\n\t\t\t\t\tnodes {\n\t\t\t\t\t\tid\n\t\t\t\t\t\tname\n\t\t\t\t\t\tkey\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tinitiatives(first: 10) {\n\t\t\t\t\tnodes {\n\t\t\t\t\t\tid\n\t\t\t\t\t\tname\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tstartDate\n\t\t\t\ttargetDate\n\t\t\t}\n\t\t}\n\t","variables":{"id":"mcp-tool-investigation-ae44897e42a7"}}' 16 | form: {} 17 | headers: 18 | Content-Type: 19 | - application/json 20 | url: https://api.linear.app/graphql 21 | method: POST 22 | response: 23 | proto: HTTP/2.0 24 | proto_major: 2 25 | proto_minor: 0 26 | transfer_encoding: [] 27 | trailer: {} 28 | content_length: -1 29 | uncompressed: true 30 | body: | 31 | {"errors":[{"message":"Entity not found: 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} 32 | headers: 33 | Alt-Svc: 34 | - h3=":443"; ma=86400 35 | Cache-Control: 36 | - no-store 37 | Cf-Cache-Status: 38 | - DYNAMIC 39 | Content-Type: 40 | - application/json; charset=utf-8 41 | Etag: 42 | - W/"113-pUQ9mkDn3KWYiQz0UBE51+d7gJ4" 43 | Server: 44 | - cloudflare 45 | Vary: 46 | - Accept-Encoding 47 | Via: 48 | - 1.1 google 49 | status: 200 OK 50 | code: 200 51 | duration: 0s 52 | - id: 1 53 | request: 54 | proto: HTTP/1.1 55 | proto_major: 1 56 | proto_minor: 1 57 | content_length: 932 58 | transfer_encoding: [] 59 | trailer: {} 60 | host: api.linear.app 61 | remote_addr: "" 62 | request_uri: "" 63 | 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-ae44897e42a7"}},{"slugId":{"eq":"ae44897e42a7"}}]}}}' 64 | form: {} 65 | headers: 66 | Content-Type: 67 | - application/json 68 | url: https://api.linear.app/graphql 69 | method: POST 70 | response: 71 | proto: HTTP/2.0 72 | proto_major: 2 73 | proto_minor: 0 74 | transfer_encoding: [] 75 | trailer: {} 76 | content_length: -1 77 | uncompressed: true 78 | body: | 79 | {"data":{"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"}]}}} 80 | headers: 81 | Alt-Svc: 82 | - h3=":443"; ma=86400 83 | Cache-Control: 84 | - no-store 85 | Cf-Cache-Status: 86 | - DYNAMIC 87 | Content-Type: 88 | - application/json; charset=utf-8 89 | Etag: 90 | - W/"355-Jji1j11utIAgJU/7ATKvhRyba4g" 91 | Server: 92 | - cloudflare 93 | Vary: 94 | - Accept-Encoding 95 | Via: 96 | - 1.1 google 97 | status: 200 OK 98 | code: 200 99 | duration: 0s 100 | ``` -------------------------------------------------------------------------------- /pkg/linear/rate_limiter.go: -------------------------------------------------------------------------------- ```go 1 | package linear 2 | 3 | import ( 4 | "fmt" 5 | "sync" 6 | "time" 7 | ) 8 | 9 | // RateLimiter manages API request rate limiting 10 | type RateLimiter struct { 11 | requestsPerHour int 12 | minDelayMs int64 13 | queue chan func() error 14 | processing bool 15 | lastRequestTime int64 16 | requestTimes []int64 17 | requestDurations []int64 18 | mu sync.Mutex 19 | } 20 | 21 | // RateLimiterMetrics contains metrics about the rate limiter 22 | type RateLimiterMetrics struct { 23 | TotalRequests int `json:"totalRequests"` 24 | RequestsInLastHour int `json:"requestsInLastHour"` 25 | AverageRequestTime int64 `json:"averageRequestTime"` 26 | QueueLength int `json:"queueLength"` 27 | LastRequestTime int64 `json:"lastRequestTime"` 28 | } 29 | 30 | // NewRateLimiter creates a new rate limiter with the specified requests per hour limit 31 | func NewRateLimiter(requestsPerHour int) *RateLimiter { 32 | minDelayMs := int64(3600000 / requestsPerHour) 33 | rl := &RateLimiter{ 34 | requestsPerHour: requestsPerHour, 35 | minDelayMs: minDelayMs, 36 | queue: make(chan func() error, 100), // Buffer size of 100 37 | processing: false, 38 | lastRequestTime: 0, 39 | requestTimes: []int64{}, 40 | requestDurations: []int64{}, 41 | } 42 | 43 | // Start the queue processor 44 | go rl.processQueue() 45 | 46 | return rl 47 | } 48 | 49 | // Enqueue adds a function to the rate limiter queue 50 | func (rl *RateLimiter) Enqueue(fn func() error, operation string) error { 51 | startTime := time.Now().UnixMilli() 52 | queuePosition := len(rl.queue) 53 | 54 | fmt.Printf("[Linear API] Enqueueing request%s (Queue position: %d)\n", 55 | formatOperation(operation), queuePosition) 56 | 57 | // Create a channel to receive the result 58 | resultCh := make(chan error, 1) 59 | 60 | // Wrap the function to capture its result 61 | wrappedFn := func() error { 62 | fmt.Printf("[Linear API] Starting request%s\n", formatOperation(operation)) 63 | result := fn() 64 | endTime := time.Now().UnixMilli() 65 | duration := endTime - startTime 66 | 67 | fmt.Printf("[Linear API] Completed request%s (Duration: %dms)\n", 68 | formatOperation(operation), duration) 69 | 70 | rl.trackRequest(startTime, endTime, operation) 71 | resultCh <- result 72 | return result 73 | } 74 | 75 | // Add to queue 76 | rl.queue <- wrappedFn 77 | 78 | // Wait for the result 79 | err := <-resultCh 80 | return err 81 | } 82 | 83 | // Batch processes a batch of items with the rate limiter 84 | func (rl *RateLimiter) Batch(items []interface{}, batchSize int, fn func(item interface{}) error, operation string) []error { 85 | results := make([]error, len(items)) 86 | var wg sync.WaitGroup 87 | 88 | for i := 0; i < len(items); i += batchSize { 89 | end := i + batchSize 90 | if end > len(items) { 91 | end = len(items) 92 | } 93 | 94 | batch := items[i:end] 95 | wg.Add(len(batch)) 96 | 97 | for j, item := range batch { 98 | index := i + j 99 | go func(idx int, itm interface{}) { 100 | defer wg.Done() 101 | err := rl.Enqueue(func() error { 102 | return fn(itm) 103 | }, operation) 104 | results[idx] = err 105 | }(index, item) 106 | } 107 | 108 | wg.Wait() 109 | } 110 | 111 | return results 112 | } 113 | 114 | // processQueue processes the queue of functions 115 | func (rl *RateLimiter) processQueue() { 116 | for { 117 | // If there are no items in the queue, wait for one 118 | fn := <-rl.queue 119 | 120 | // Process the item 121 | rl.mu.Lock() 122 | now := time.Now().UnixMilli() 123 | timeSinceLastRequest := now - rl.lastRequestTime 124 | 125 | // Check if we need to wait to respect rate limits 126 | requestsInLastHour := 0 127 | oneHourAgo := now - 3600000 128 | for _, t := range rl.requestTimes { 129 | if t > oneHourAgo { 130 | requestsInLastHour++ 131 | } 132 | } 133 | 134 | if requestsInLastHour >= int(float64(rl.requestsPerHour)*0.9) && timeSinceLastRequest < rl.minDelayMs { 135 | waitTime := rl.minDelayMs - timeSinceLastRequest 136 | rl.mu.Unlock() 137 | time.Sleep(time.Duration(waitTime) * time.Millisecond) 138 | } else { 139 | rl.mu.Unlock() 140 | } 141 | 142 | // Execute the function 143 | rl.mu.Lock() 144 | rl.lastRequestTime = time.Now().UnixMilli() 145 | rl.mu.Unlock() 146 | 147 | _ = fn() // Execute the function 148 | } 149 | } 150 | 151 | // trackRequest tracks a request for metrics 152 | func (rl *RateLimiter) trackRequest(startTime, endTime int64, operation string) { 153 | duration := endTime - startTime 154 | 155 | rl.mu.Lock() 156 | defer rl.mu.Unlock() 157 | 158 | rl.requestTimes = append(rl.requestTimes, startTime) 159 | rl.requestDurations = append(rl.requestDurations, duration) 160 | 161 | // Keep only the last hour of requests 162 | oneHourAgo := time.Now().UnixMilli() - 3600000 163 | var newRequestTimes []int64 164 | var newRequestDurations []int64 165 | 166 | for i, t := range rl.requestTimes { 167 | if t > oneHourAgo { 168 | newRequestTimes = append(newRequestTimes, t) 169 | newRequestDurations = append(newRequestDurations, rl.requestDurations[i]) 170 | } 171 | } 172 | 173 | rl.requestTimes = newRequestTimes 174 | rl.requestDurations = newRequestDurations 175 | } 176 | 177 | // GetMetrics returns metrics about the rate limiter 178 | func (rl *RateLimiter) GetMetrics() RateLimiterMetrics { 179 | rl.mu.Lock() 180 | defer rl.mu.Unlock() 181 | 182 | now := time.Now().UnixMilli() 183 | oneHourAgo := now - 3600000 184 | recentRequests := 0 185 | 186 | for _, t := range rl.requestTimes { 187 | if t > oneHourAgo { 188 | recentRequests++ 189 | } 190 | } 191 | 192 | var avgRequestTime int64 = 0 193 | if len(rl.requestDurations) > 0 { 194 | var sum int64 = 0 195 | for _, d := range rl.requestDurations { 196 | sum += d 197 | } 198 | avgRequestTime = sum / int64(len(rl.requestDurations)) 199 | } 200 | 201 | return RateLimiterMetrics{ 202 | TotalRequests: len(rl.requestTimes), 203 | RequestsInLastHour: recentRequests, 204 | AverageRequestTime: avgRequestTime, 205 | QueueLength: len(rl.queue), 206 | LastRequestTime: rl.lastRequestTime, 207 | } 208 | } 209 | 210 | // Helper function to format operation name for logging 211 | func formatOperation(operation string) string { 212 | if operation != "" { 213 | return " for " + operation 214 | } 215 | return "" 216 | } 217 | ``` -------------------------------------------------------------------------------- /pkg/tools/milestone_tools.go: -------------------------------------------------------------------------------- ```go 1 | package tools 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strings" 7 | 8 | "github.com/geropl/linear-mcp-go/pkg/linear" 9 | "github.com/mark3labs/mcp-go/mcp" 10 | ) 11 | 12 | var GetMilestoneTool = mcp.NewTool("linear_get_milestone", 13 | mcp.WithDescription("Get a single project milestone by its ID or name."), 14 | mcp.WithString("milestone", mcp.Required(), mcp.Description("The ID or name of the project milestone to get.")), 15 | ) 16 | 17 | func GetMilestoneHandler(linearClient *linear.LinearClient) func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { 18 | return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { 19 | milestoneIdentifier, err := request.RequireString("milestone") 20 | if err != nil { 21 | return &mcp.CallToolResult{IsError: true, Content: []mcp.Content{mcp.TextContent{Type: "text", Text: err.Error()}}}, nil 22 | } 23 | 24 | milestone, err := linearClient.GetMilestone(milestoneIdentifier) 25 | if err != nil { 26 | return &mcp.CallToolResult{IsError: true, Content: []mcp.Content{mcp.TextContent{Type: "text", Text: fmt.Sprintf("Failed to get milestone: %v", err)}}}, nil 27 | } 28 | 29 | resultText := FormatMilestone(*milestone) 30 | 31 | return &mcp.CallToolResult{Content: []mcp.Content{mcp.TextContent{Type: "text", Text: resultText}}}, nil 32 | } 33 | } 34 | 35 | func FormatMilestone(milestone linear.ProjectMilestone) string { 36 | var builder strings.Builder 37 | builder.WriteString(fmt.Sprintf("Milestone: %s\n", milestone.Name)) 38 | builder.WriteString(fmt.Sprintf(" ID: %s\n", milestone.ID)) 39 | if milestone.Description != "" { 40 | builder.WriteString(fmt.Sprintf(" Description: %s\n", milestone.Description)) 41 | } 42 | if milestone.TargetDate != nil { 43 | builder.WriteString(fmt.Sprintf(" Target Date: %s\n", *milestone.TargetDate)) 44 | } 45 | if milestone.Project != nil { 46 | builder.WriteString(fmt.Sprintf(" Project: %s (%s)\n", milestone.Project.Name, milestone.Project.ID)) 47 | } 48 | return builder.String() 49 | } 50 | 51 | var UpdateMilestoneTool = mcp.NewTool("linear_update_milestone", 52 | mcp.WithDescription("Update an existing project milestone."), 53 | mcp.WithString("milestone", mcp.Required(), mcp.Description("The ID or name of the milestone to update.")), 54 | mcp.WithString("name", mcp.Description("The new name of the milestone.")), 55 | mcp.WithString("description", mcp.Description("The new description of the milestone.")), 56 | mcp.WithString("targetDate", mcp.Description("The new target date of the milestone (YYYY-MM-DD).")), 57 | ) 58 | 59 | func UpdateMilestoneHandler(linearClient *linear.LinearClient) func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { 60 | return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { 61 | milestoneIdentifier, err := request.RequireString("milestone") 62 | if err != nil { 63 | return &mcp.CallToolResult{IsError: true, Content: []mcp.Content{mcp.TextContent{Type: "text", Text: err.Error()}}}, nil 64 | } 65 | 66 | // Get the milestone first to get its ID 67 | mil, err := linearClient.GetMilestone(milestoneIdentifier) 68 | if err != nil { 69 | return &mcp.CallToolResult{IsError: true, Content: []mcp.Content{mcp.TextContent{Type: "text", Text: fmt.Sprintf("Failed to get milestone: %v", err)}}}, nil 70 | } 71 | 72 | name := request.GetString("name", "") 73 | description := request.GetString("description", "") 74 | targetDate := request.GetString("targetDate", "") 75 | 76 | input := linear.ProjectMilestoneUpdateInput{ 77 | Name: name, 78 | Description: description, 79 | TargetDate: targetDate, 80 | } 81 | 82 | milestone, err := linearClient.UpdateMilestone(mil.ID, input) 83 | if err != nil { 84 | return &mcp.CallToolResult{IsError: true, Content: []mcp.Content{mcp.TextContent{Type: "text", Text: fmt.Sprintf("Failed to update milestone: %v", err)}}}, nil 85 | } 86 | 87 | resultText := FormatMilestone(*milestone) 88 | 89 | return &mcp.CallToolResult{Content: []mcp.Content{mcp.TextContent{Type: "text", Text: resultText}}}, nil 90 | } 91 | } 92 | 93 | var CreateMilestoneTool = mcp.NewTool("linear_create_milestone", 94 | mcp.WithDescription("Create a new project milestone."), 95 | mcp.WithString("name", mcp.Required(), mcp.Description("The name of the milestone.")), 96 | mcp.WithString("projectId", mcp.Required(), mcp.Description("The ID of the project to create the milestone in.")), 97 | mcp.WithString("description", mcp.Description("The description of the milestone.")), 98 | mcp.WithString("targetDate", mcp.Description("The target date of the milestone (YYYY-MM-DD).")), 99 | ) 100 | 101 | func CreateMilestoneHandler(linearClient *linear.LinearClient) func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { 102 | return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { 103 | name, err := request.RequireString("name") 104 | if err != nil { 105 | return &mcp.CallToolResult{IsError: true, Content: []mcp.Content{mcp.TextContent{Type: "text", Text: err.Error()}}}, nil 106 | } 107 | 108 | projectID, err := request.RequireString("projectId") 109 | if err != nil { 110 | return &mcp.CallToolResult{IsError: true, Content: []mcp.Content{mcp.TextContent{Type: "text", Text: err.Error()}}}, nil 111 | } 112 | 113 | description := request.GetString("description", "") 114 | targetDate := request.GetString("targetDate", "") 115 | 116 | input := linear.ProjectMilestoneCreateInput{ 117 | Name: name, 118 | ProjectID: projectID, 119 | Description: description, 120 | TargetDate: targetDate, 121 | } 122 | 123 | milestone, err := linearClient.CreateMilestone(input) 124 | if err != nil { 125 | return &mcp.CallToolResult{IsError: true, Content: []mcp.Content{mcp.TextContent{Type: "text", Text: fmt.Sprintf("Failed to create milestone: %v", err)}}}, nil 126 | } 127 | 128 | resultText := FormatMilestone(*milestone) 129 | 130 | return &mcp.CallToolResult{Content: []mcp.Content{mcp.TextContent{Type: "text", Text: resultText}}}, nil 131 | } 132 | } 133 | ``` -------------------------------------------------------------------------------- /testdata/fixtures/add_comment_handler_Reply with shorthand.yaml: -------------------------------------------------------------------------------- ```yaml 1 | --- 2 | version: 2 3 | interactions: 4 | - id: 0 5 | request: 6 | proto: HTTP/1.1 7 | proto_major: 1 8 | proto_minor: 1 9 | content_length: 322 10 | transfer_encoding: [] 11 | trailer: {} 12 | host: api.linear.app 13 | remote_addr: "" 14 | request_uri: "" 15 | body: '{"query":"\n\t\tquery GetIssueByIdentifier($teamKey: String!, $number: Float!) {\n\t\t\tissues(filter: { team: { key: { eq: $teamKey } }, number: { eq: $number } }, first: 1) {\n\t\t\t\tnodes {\n\t\t\t\t\tid\n\t\t\t\t\tidentifier\n\t\t\t\t\ttitle\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t","variables":{"number":10,"teamKey":"TEST"}}' 16 | form: {} 17 | headers: 18 | Content-Type: 19 | - application/json 20 | url: https://api.linear.app/graphql 21 | method: POST 22 | response: 23 | proto: HTTP/2.0 24 | proto_major: 2 25 | proto_minor: 0 26 | transfer_encoding: [] 27 | trailer: {} 28 | content_length: -1 29 | uncompressed: true 30 | body: | 31 | {"data":{"issues":{"nodes":[{"id":"1c2de93f-4321-4015-bfde-ee893ef7976f","identifier":"TEST-10","title":"Updated Test Issue"}]}}} 32 | headers: 33 | Alt-Svc: 34 | - h3=":443"; ma=86400 35 | Cache-Control: 36 | - no-store 37 | Cf-Cache-Status: 38 | - DYNAMIC 39 | Content-Type: 40 | - application/json; charset=utf-8 41 | Etag: 42 | - W/"82-w0K/VnjlqJtYAurPyBwU/9QgAFo" 43 | Server: 44 | - cloudflare 45 | Vary: 46 | - Accept-Encoding 47 | Via: 48 | - 1.1 google 49 | status: 200 OK 50 | code: 200 51 | duration: 0s 52 | - id: 1 53 | request: 54 | proto: HTTP/1.1 55 | proto_major: 1 56 | proto_minor: 1 57 | content_length: 255 58 | transfer_encoding: [] 59 | trailer: {} 60 | host: api.linear.app 61 | remote_addr: "" 62 | request_uri: "" 63 | 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"}}' 64 | form: {} 65 | headers: 66 | Content-Type: 67 | - application/json 68 | url: https://api.linear.app/graphql 69 | method: POST 70 | response: 71 | proto: HTTP/2.0 72 | proto_major: 2 73 | proto_minor: 0 74 | transfer_encoding: [] 75 | trailer: {} 76 | content_length: -1 77 | uncompressed: true 78 | body: | 79 | {"data":{"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"}}}} 80 | headers: 81 | Alt-Svc: 82 | - h3=":443"; ma=86400 83 | Cache-Control: 84 | - no-store 85 | Cf-Cache-Status: 86 | - DYNAMIC 87 | Content-Type: 88 | - application/json; charset=utf-8 89 | Etag: 90 | - W/"13e-dbWT1UL53WBZl2BVydYcoWd4NlQ" 91 | Server: 92 | - cloudflare 93 | Vary: 94 | - Accept-Encoding 95 | Via: 96 | - 1.1 google 97 | status: 200 OK 98 | code: 200 99 | duration: 0s 100 | - id: 2 101 | request: 102 | proto: HTTP/1.1 103 | proto_major: 1 104 | proto_minor: 1 105 | content_length: 566 106 | transfer_encoding: [] 107 | trailer: {} 108 | host: api.linear.app 109 | remote_addr: "" 110 | request_uri: "" 111 | 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":"Reply using shorthand","issueId":"1c2de93f-4321-4015-bfde-ee893ef7976f","parentId":"ae3d62d6-3f40-4990-867b-5c97dd265a40"}}}' 112 | form: {} 113 | headers: 114 | Content-Type: 115 | - application/json 116 | url: https://api.linear.app/graphql 117 | method: POST 118 | response: 119 | proto: HTTP/2.0 120 | proto_major: 2 121 | proto_minor: 0 122 | transfer_encoding: [] 123 | trailer: {} 124 | content_length: -1 125 | uncompressed: true 126 | body: | 127 | {"data":{"commentCreate":{"success":true,"comment":{"id":"c414a6fe-c32c-40bf-a57e-07f032330bc3","body":"Reply using shorthand","url":"https://linear.app/linear-mcp-go-test/issue/TEST-10/updated-test-issue#comment-c414a6fe","createdAt":"2025-10-07T16:13:08.292Z","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"}}}}} 128 | headers: 129 | Alt-Svc: 130 | - h3=":443"; ma=86400 131 | Cache-Control: 132 | - no-store 133 | Cf-Cache-Status: 134 | - DYNAMIC 135 | Content-Type: 136 | - application/json; charset=utf-8 137 | Etag: 138 | - W/"210-nCgczohGn3/AmxaCQPEau4rHXRE" 139 | Server: 140 | - cloudflare 141 | Vary: 142 | - Accept-Encoding 143 | Via: 144 | - 1.1 google 145 | status: 200 OK 146 | code: 200 147 | duration: 0s 148 | ``` -------------------------------------------------------------------------------- /testdata/fixtures/add_comment_handler_Reply with URL.yaml: -------------------------------------------------------------------------------- ```yaml 1 | --- 2 | version: 2 3 | interactions: 4 | - id: 0 5 | request: 6 | proto: HTTP/1.1 7 | proto_major: 1 8 | proto_minor: 1 9 | content_length: 322 10 | transfer_encoding: [] 11 | trailer: {} 12 | host: api.linear.app 13 | remote_addr: "" 14 | request_uri: "" 15 | body: '{"query":"\n\t\tquery GetIssueByIdentifier($teamKey: String!, $number: Float!) {\n\t\t\tissues(filter: { team: { key: { eq: $teamKey } }, number: { eq: $number } }, first: 1) {\n\t\t\t\tnodes {\n\t\t\t\t\tid\n\t\t\t\t\tidentifier\n\t\t\t\t\ttitle\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t","variables":{"number":10,"teamKey":"TEST"}}' 16 | form: {} 17 | headers: 18 | Content-Type: 19 | - application/json 20 | url: https://api.linear.app/graphql 21 | method: POST 22 | response: 23 | proto: HTTP/2.0 24 | proto_major: 2 25 | proto_minor: 0 26 | transfer_encoding: [] 27 | trailer: {} 28 | content_length: -1 29 | uncompressed: true 30 | body: | 31 | {"data":{"issues":{"nodes":[{"id":"1c2de93f-4321-4015-bfde-ee893ef7976f","identifier":"TEST-10","title":"Updated Test Issue"}]}}} 32 | headers: 33 | Alt-Svc: 34 | - h3=":443"; ma=86400 35 | Cache-Control: 36 | - no-store 37 | Cf-Cache-Status: 38 | - DYNAMIC 39 | Content-Type: 40 | - application/json; charset=utf-8 41 | Etag: 42 | - W/"82-w0K/VnjlqJtYAurPyBwU/9QgAFo" 43 | Server: 44 | - cloudflare 45 | Vary: 46 | - Accept-Encoding 47 | Via: 48 | - 1.1 google 49 | status: 200 OK 50 | code: 200 51 | duration: 0s 52 | - id: 1 53 | request: 54 | proto: HTTP/1.1 55 | proto_major: 1 56 | proto_minor: 1 57 | content_length: 255 58 | transfer_encoding: [] 59 | trailer: {} 60 | host: api.linear.app 61 | remote_addr: "" 62 | request_uri: "" 63 | 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"}}' 64 | form: {} 65 | headers: 66 | Content-Type: 67 | - application/json 68 | url: https://api.linear.app/graphql 69 | method: POST 70 | response: 71 | proto: HTTP/2.0 72 | proto_major: 2 73 | proto_minor: 0 74 | transfer_encoding: [] 75 | trailer: {} 76 | content_length: -1 77 | uncompressed: true 78 | body: | 79 | {"data":{"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"}}}} 80 | headers: 81 | Alt-Svc: 82 | - h3=":443"; ma=86400 83 | Cache-Control: 84 | - no-store 85 | Cf-Cache-Status: 86 | - DYNAMIC 87 | Content-Type: 88 | - application/json; charset=utf-8 89 | Etag: 90 | - W/"13e-dbWT1UL53WBZl2BVydYcoWd4NlQ" 91 | Server: 92 | - cloudflare 93 | Vary: 94 | - Accept-Encoding 95 | Via: 96 | - 1.1 google 97 | status: 200 OK 98 | code: 200 99 | duration: 0s 100 | - id: 2 101 | request: 102 | proto: HTTP/1.1 103 | proto_major: 1 104 | proto_minor: 1 105 | content_length: 568 106 | transfer_encoding: [] 107 | trailer: {} 108 | host: api.linear.app 109 | remote_addr: "" 110 | request_uri: "" 111 | 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":"Reply using comment URL","issueId":"1c2de93f-4321-4015-bfde-ee893ef7976f","parentId":"ae3d62d6-3f40-4990-867b-5c97dd265a40"}}}' 112 | form: {} 113 | headers: 114 | Content-Type: 115 | - application/json 116 | url: https://api.linear.app/graphql 117 | method: POST 118 | response: 119 | proto: HTTP/2.0 120 | proto_major: 2 121 | proto_minor: 0 122 | transfer_encoding: [] 123 | trailer: {} 124 | content_length: -1 125 | uncompressed: true 126 | body: | 127 | {"data":{"commentCreate":{"success":true,"comment":{"id":"3c4e53f0-6fef-4d32-a03d-388333aa0157","body":"Reply using comment URL","url":"https://linear.app/linear-mcp-go-test/issue/TEST-10/updated-test-issue#comment-3c4e53f0","createdAt":"2025-10-07T16:13:07.925Z","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"}}}}} 128 | headers: 129 | Alt-Svc: 130 | - h3=":443"; ma=86400 131 | Cache-Control: 132 | - no-store 133 | Cf-Cache-Status: 134 | - DYNAMIC 135 | Content-Type: 136 | - application/json; charset=utf-8 137 | Etag: 138 | - W/"212-gMY3ZFQiv9fC2AkRJXlMJ63RLWo" 139 | Server: 140 | - cloudflare 141 | Vary: 142 | - Accept-Encoding 143 | Via: 144 | - 1.1 google 145 | status: 200 OK 146 | code: 200 147 | duration: 0s 148 | ``` -------------------------------------------------------------------------------- /testdata/fixtures/reply_to_comment_handler_Reply with URL.yaml: -------------------------------------------------------------------------------- ```yaml 1 | --- 2 | version: 2 3 | interactions: 4 | - id: 0 5 | request: 6 | proto: HTTP/1.1 7 | proto_major: 1 8 | proto_minor: 1 9 | content_length: 255 10 | transfer_encoding: [] 11 | trailer: {} 12 | host: api.linear.app 13 | remote_addr: "" 14 | request_uri: "" 15 | body: '{"query":"\n\t\tquery GetCommentByHash($hash: String!) {\n\t\t\tcomment(hash: $hash) {\n\t\t\t\tid\n\t\t\t\tbody\n\t\t\t\turl\n\t\t\t\tcreatedAt\n\t\t\t\tuser {\n\t\t\t\t\tid\n\t\t\t\t\tname\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t","variables":{"hash":"ae3d62d6"}}' 16 | form: {} 17 | headers: 18 | Content-Type: 19 | - application/json 20 | url: https://api.linear.app/graphql 21 | method: POST 22 | response: 23 | proto: HTTP/2.0 24 | proto_major: 2 25 | proto_minor: 0 26 | transfer_encoding: [] 27 | trailer: {} 28 | content_length: -1 29 | uncompressed: true 30 | body: | 31 | {"data":{"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"}}}} 32 | headers: 33 | Alt-Svc: 34 | - h3=":443"; ma=86400 35 | Cache-Control: 36 | - no-store 37 | Cf-Cache-Status: 38 | - DYNAMIC 39 | Content-Type: 40 | - application/json; charset=utf-8 41 | Etag: 42 | - W/"13e-dbWT1UL53WBZl2BVydYcoWd4NlQ" 43 | Server: 44 | - cloudflare 45 | Vary: 46 | - Accept-Encoding 47 | Via: 48 | - 1.1 google 49 | status: 200 OK 50 | code: 200 51 | duration: 0s 52 | - id: 1 53 | request: 54 | proto: HTTP/1.1 55 | proto_major: 1 56 | proto_minor: 1 57 | content_length: 333 58 | transfer_encoding: [] 59 | trailer: {} 60 | host: api.linear.app 61 | remote_addr: "" 62 | request_uri: "" 63 | 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"}}' 64 | form: {} 65 | headers: 66 | Content-Type: 67 | - application/json 68 | url: https://api.linear.app/graphql 69 | method: POST 70 | response: 71 | proto: HTTP/2.0 72 | proto_major: 2 73 | proto_minor: 0 74 | transfer_encoding: [] 75 | trailer: {} 76 | content_length: -1 77 | uncompressed: true 78 | body: | 79 | {"data":{"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"}}}} 80 | headers: 81 | Alt-Svc: 82 | - h3=":443"; ma=86400 83 | Cache-Control: 84 | - no-store 85 | Cf-Cache-Status: 86 | - DYNAMIC 87 | Content-Type: 88 | - application/json; charset=utf-8 89 | Etag: 90 | - W/"18b-JtoAyZ6gspXHMOEbvedWimpK4Y0" 91 | Server: 92 | - cloudflare 93 | Vary: 94 | - Accept-Encoding 95 | Via: 96 | - 1.1 google 97 | status: 200 OK 98 | code: 200 99 | duration: 0s 100 | - id: 2 101 | request: 102 | proto: HTTP/1.1 103 | proto_major: 1 104 | proto_minor: 1 105 | content_length: 578 106 | transfer_encoding: [] 107 | trailer: {} 108 | host: api.linear.app 109 | remote_addr: "" 110 | request_uri: "" 111 | 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":"Reply using URL in dedicated tool","issueId":"1c2de93f-4321-4015-bfde-ee893ef7976f","parentId":"ae3d62d6-3f40-4990-867b-5c97dd265a40"}}}' 112 | form: {} 113 | headers: 114 | Content-Type: 115 | - application/json 116 | url: https://api.linear.app/graphql 117 | method: POST 118 | response: 119 | proto: HTTP/2.0 120 | proto_major: 2 121 | proto_minor: 0 122 | transfer_encoding: [] 123 | trailer: {} 124 | content_length: -1 125 | uncompressed: true 126 | body: | 127 | {"data":{"commentCreate":{"success":true,"comment":{"id":"9f06f784-4132-4fae-bf2c-9065365759e3","body":"Reply using URL in dedicated tool","url":"https://linear.app/linear-mcp-go-test/issue/TEST-10/updated-test-issue#comment-9f06f784","createdAt":"2025-10-07T13:55:14.826Z","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"}}}}} 128 | headers: 129 | Alt-Svc: 130 | - h3=":443"; ma=86400 131 | Cache-Control: 132 | - no-store 133 | Cf-Cache-Status: 134 | - DYNAMIC 135 | Content-Type: 136 | - application/json; charset=utf-8 137 | Etag: 138 | - W/"21c-IIg5wnDeEvhS6hopc1qa1TAVMgM" 139 | Server: 140 | - cloudflare 141 | Vary: 142 | - Accept-Encoding 143 | Via: 144 | - 1.1 google 145 | status: 200 OK 146 | code: 200 147 | duration: 0s 148 | ``` -------------------------------------------------------------------------------- /docs/prd/001-api-refresher.md: -------------------------------------------------------------------------------- ```markdown 1 | # PRD: Enhance `linear_create_issue` Tool 2 | 3 | **Version:** 1.2 4 | **Date:** 2025-03-30 5 | **Status:** Proposed 6 | 7 | ## 1. Introduction 8 | 9 | This document outlines the requirements for completing the implementation of enhancements to the `linear_create_issue` MCP tool within the Linear MCP Server project. This includes adding support for more user-friendly label and parent issue identifiers. 10 | 11 | ## 2. Goals 12 | 13 | - Enable users to create sub-issues by specifying a parent issue ID or identifier (e.g., "TEAM-123"). 14 | - Enable users to assign labels during issue creation by specifying label IDs or names. 15 | - Ensure the underlying Linear API client supports these new parameters. 16 | - Maintain consistency with existing tool design and project patterns. 17 | - Provide comprehensive test coverage for the new functionality. 18 | 19 | ## 3. Requirements / Implementation Plan 20 | 21 | The following steps are required to complete the implementation: 22 | 23 | 1. **Modify Linear Client Data Structures (`pkg/linear/models.go`):** 24 | * No changes needed. 25 | 26 | 2. **Update Linear Client API Call (`pkg/linear/client.go`):** 27 | * No changes needed. 28 | 29 | 3. **Enhance MCP Tool Definition (`pkg/server/tools.go`):** 30 | * In `RegisterTools`, update the `linear_create_issue` tool definition: 31 | * Update `parentIssue` description to: "Optional parent issue ID or identifier (e.g., 'TEAM-123') to create a sub-issue". 32 | * Update `labels` description to: "Optional comma-separated list of label IDs or names to assign". 33 | 34 | 4. **Implement Label Name Resolution (`pkg/server/tools.go`):** 35 | * Create a `resolveLabelIdentifiers` function that: 36 | * Takes a Linear client, team ID, and comma-separated string of label names/UUIDs 37 | * Splits the string into individual identifiers 38 | * For each identifier: 39 | * If it's a valid UUID, add it directly to the result list 40 | * Otherwise, query the Linear API for labels with the given name 41 | * If a label is not found, return an error 42 | * Returns a slice of label UUIDs 43 | * GraphQL query for finding labels by name: 44 | ```graphql 45 | query GetLabelsByName($teamId: ID!, $names: [String!]!) { 46 | team(id: $teamId) { 47 | labels(filter: { name: { in: $names } }) { 48 | nodes { 49 | id 50 | name 51 | } 52 | } 53 | } 54 | } 55 | ``` 56 | 57 | 5. **Implement Parent Issue Identifier Resolution (`pkg/server/tools.go`):** 58 | * Create a `resolveParentIssueIdentifier` function that: 59 | * Takes a Linear client and parent issue identifier (UUID or "TEAM-123") 60 | * If it's a valid UUID, returns it directly 61 | * Otherwise, queries the Linear API for an issue with that identifier 62 | * If an issue is found, returns its UUID 63 | * If an issue is not found, returns an error 64 | * GraphQL query for finding an issue by identifier: 65 | ```graphql 66 | query GetIssueByIdentifier($identifier: String!) { 67 | issueSearch(filter: { identifier: { eq: $identifier } }, first: 1) { 68 | nodes { 69 | id 70 | } 71 | } 72 | } 73 | ``` 74 | 75 | 6. **Update MCP Tool Handler (`pkg/server/tools.go`):** 76 | * In `CreateIssueHandler`: 77 | * Extract optional `parentIssue` and `labels` arguments 78 | * If `parentIssue` is present, use `resolveParentIssueIdentifier` to get the parent issue UUID 79 | * If `labels` is present, use `resolveLabelIdentifiers` to get the label UUIDs 80 | * Update error handling to provide clear messages when resolution fails 81 | * Populate the `ParentID` and `LabelIDs` fields in the `linear.CreateIssueInput` struct 82 | 83 | 7. **Expand Test Coverage (`pkg/server/tools_test.go`):** 84 | * Add new test cases for `create_issue`: 85 | * Test creating a sub-issue using the issue identifier (e.g., "TEST-123") 86 | * Test creating an issue with labels using label names 87 | * Test creating a sub-issue with labels using a mix of label names and UUIDs 88 | * Update existing test names and potentially arguments if needed for clarity 89 | 90 | 8. **Update Test Fixtures (`testdata/fixtures/` & `testdata/golden/`):** 91 | * Re-record VCR fixtures for new/modified `create_issue_handler` tests using `go test -v -recordWrites=true ./...` 92 | * Requires a valid `LINEAR_API_KEY` 93 | * Requires appropriate parent issues and labels in the test workspace 94 | * Update corresponding `.golden` files with expected output 95 | 96 | 9. **Documentation (`README.md`, `memory-bank/`):** 97 | * Update `README.md` usage examples for `linear_create_issue` 98 | * Update `memory-bank/progress.md` upon completion 99 | * Update `.clinerules` if new patterns emerge 100 | 101 | ## 4. Resolution Workflows 102 | 103 | ### Label Resolution Workflow 104 | 105 | ```mermaid 106 | flowchart TD 107 | A[Input: Comma-separated label names/UUIDs] --> B[Split into individual identifiers] 108 | B --> C{For each identifier} 109 | C --> D{Is valid UUID?} 110 | D -->|Yes| E[Add to result list] 111 | D -->|No| F[Query Linear API for label by name] 112 | F --> G{Label found?} 113 | G -->|Yes| H[Add label UUID to result list] 114 | G -->|No| I[Return error] 115 | H --> C 116 | E --> C 117 | C --> J[Return list of label UUIDs] 118 | ``` 119 | 120 | ### Parent Issue Resolution Workflow 121 | 122 | ```mermaid 123 | flowchart TD 124 | A[Input: Parent issue identifier] --> B{Is valid UUID?} 125 | B -->|Yes| C[Return UUID directly] 126 | B -->|No| D[Query Linear API for issue by identifier] 127 | D --> E{Issue found?} 128 | E -->|Yes| F[Return issue UUID] 129 | E -->|No| G[Return error] 130 | ``` 131 | 132 | ## 5. Success Criteria 133 | 134 | - The `linear_create_issue` tool successfully creates sub-issues when `parentIssue` is provided as an ID or identifier. 135 | - The `linear_create_issue` tool successfully assigns labels when `labels` are provided as IDs or names. 136 | - All new test cases pass, including VCR playback. 137 | - Documentation accurately reflects the tool's updated capabilities. 138 | 139 | ## 6. Implementation Decisions 140 | 141 | - **Input Validation**: Delegate to Linear API for input validation as much as possible, rather than implementing extensive client-side validation. 142 | - **Label Limits**: No limits will be imposed on the number of labels that can be assigned at once. 143 | - **Error Handling**: Provide clear error messages when resolution fails, but rely on Linear API for validation of the resolved UUIDs. 144 | ``` -------------------------------------------------------------------------------- /pkg/tools/common.go: -------------------------------------------------------------------------------- ```go 1 | package tools 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/geropl/linear-mcp-go/pkg/linear" 8 | "github.com/google/uuid" 9 | ) 10 | 11 | // resolveIssueIdentifier resolves an issue identifier (UUID or "TEAM-123") to a UUID 12 | func resolveIssueIdentifier(linearClient *linear.LinearClient, identifier string) (string, error) { 13 | // If it's a valid UUID, use it directly 14 | if isValidUUID(identifier) { 15 | return identifier, nil 16 | } 17 | 18 | // Otherwise, try to find an issue by identifier 19 | issue, err := linearClient.GetIssueByIdentifier(identifier) 20 | if err != nil { 21 | return "", fmt.Errorf("failed to resolve issue identifier '%s': %v", identifier, err) 22 | } 23 | 24 | return issue.ID, nil 25 | } 26 | 27 | // resolveParentIssueIdentifier is an alias for resolveIssueIdentifier for backward compatibility 28 | func resolveParentIssueIdentifier(linearClient *linear.LinearClient, identifier string) (string, error) { 29 | return resolveIssueIdentifier(linearClient, identifier) 30 | } 31 | 32 | // resolveUserIdentifier resolves a user identifier (UUID, name, or email) to a UUID 33 | func resolveUserIdentifier(linearClient *linear.LinearClient, identifier string) (string, error) { 34 | // If it's a valid UUID, use it directly 35 | if isValidUUID(identifier) { 36 | return identifier, nil 37 | } 38 | 39 | // Otherwise, try to find a user by name or email 40 | // Get the organization to access all users 41 | org, err := linearClient.GetOrganization() 42 | if err != nil { 43 | return "", fmt.Errorf("failed to get organization: %v", err) 44 | } 45 | 46 | // First try exact match on name or email 47 | for _, user := range org.Users { 48 | if user.Name == identifier || user.Email == identifier { 49 | return user.ID, nil 50 | } 51 | } 52 | 53 | // If no exact match, try case-insensitive match 54 | identifierLower := strings.ToLower(identifier) 55 | for _, user := range org.Users { 56 | if strings.ToLower(user.Name) == identifierLower || strings.ToLower(user.Email) == identifierLower { 57 | return user.ID, nil 58 | } 59 | } 60 | 61 | return "", fmt.Errorf("no user found with identifier '%s'", identifier) 62 | } 63 | 64 | // resolveLabelIdentifiers resolves a list of label identifiers (UUIDs or names) to UUIDs 65 | func resolveLabelIdentifiers(linearClient *linear.LinearClient, teamID string, labelIdentifiers []string) ([]string, error) { 66 | // Separate UUIDs and names 67 | var labelUUIDs []string 68 | var labelNames []string 69 | 70 | for _, identifier := range labelIdentifiers { 71 | if isValidUUID(identifier) { 72 | labelUUIDs = append(labelUUIDs, identifier) 73 | } else { 74 | labelNames = append(labelNames, identifier) 75 | } 76 | } 77 | 78 | // If there are no names to resolve, return the UUIDs directly 79 | if len(labelNames) == 0 { 80 | return labelUUIDs, nil 81 | } 82 | 83 | // Get labels by name 84 | labels, err := linearClient.GetLabelsByName(teamID, labelNames) 85 | if err != nil { 86 | return nil, fmt.Errorf("failed to get labels by name: %v", err) 87 | } 88 | 89 | // Check if all label names were found 90 | if len(labels) < len(labelNames) { 91 | // Create a map of found label names for quick lookup 92 | foundLabels := make(map[string]bool) 93 | for _, label := range labels { 94 | foundLabels[label.Name] = true 95 | } 96 | 97 | // Find which label names were not found 98 | var missingLabels []string 99 | for _, name := range labelNames { 100 | if !foundLabels[name] { 101 | missingLabels = append(missingLabels, name) 102 | } 103 | } 104 | 105 | return nil, fmt.Errorf("label(s) not found: %s", strings.Join(missingLabels, ", ")) 106 | } 107 | 108 | // Add the resolved label UUIDs to the result 109 | for _, label := range labels { 110 | labelUUIDs = append(labelUUIDs, label.ID) 111 | } 112 | 113 | return labelUUIDs, nil 114 | } 115 | 116 | // isValidUUID checks if a string is a valid UUID 117 | func isValidUUID(uuidStr string) bool { 118 | return uuid.Validate(uuidStr) == nil 119 | } 120 | 121 | // extractCommentHashFromURL extracts the comment hash from various URL formats 122 | // Supports: 123 | // - https://linear.app/.../issue/TEST-10/...#comment-abc123 124 | // - #comment-abc123 125 | func extractCommentHashFromURL(identifier string) string { 126 | // Check if it contains a URL fragment with comment 127 | if strings.Contains(identifier, "#comment-") { 128 | // Extract everything after #comment- 129 | parts := strings.Split(identifier, "#comment-") 130 | if len(parts) >= 2 { 131 | return parts[1] 132 | } 133 | } 134 | return "" 135 | } 136 | 137 | // resolveCommentIdentifier resolves a comment identifier (UUID, URL, or shorthand like "comment-53099b37") to a UUID 138 | func resolveCommentIdentifier(linearClient *linear.LinearClient, identifier string) (string, error) { 139 | // If it's a valid UUID, use it directly 140 | if isValidUUID(identifier) { 141 | return identifier, nil 142 | } 143 | 144 | // Try to extract hash from URL or fragment 145 | var hash string 146 | if strings.HasPrefix(identifier, "comment-") { 147 | hash = strings.TrimPrefix(identifier, "comment-") 148 | } else if strings.Contains(identifier, "linear.app") && strings.Contains(identifier, "#comment-") { 149 | hash = extractCommentHashFromURL(identifier) 150 | } 151 | 152 | if hash == "" { 153 | // Fallback: assume it's already just the hash part 154 | hash = identifier 155 | } 156 | comment, err := linearClient.GetCommentByHash(hash) 157 | if err != nil { 158 | return "", fmt.Errorf("failed to resolve comment identifier '%s': %v", identifier, err) 159 | } 160 | 161 | return comment.ID, nil 162 | } 163 | 164 | // resolveTeamIdentifier resolves a team identifier (UUID, name, or key) to a team ID 165 | func resolveTeamIdentifier(linearClient *linear.LinearClient, identifier string) (string, error) { 166 | // If it's a valid UUID, use it directly 167 | if isValidUUID(identifier) { 168 | return identifier, nil 169 | } 170 | 171 | // Otherwise, try to find a team by name or key 172 | teams, err := linearClient.GetTeams("") 173 | if err != nil { 174 | return "", fmt.Errorf("failed to get teams: %v", err) 175 | } 176 | 177 | // First try exact match on name or key 178 | for _, team := range teams { 179 | if team.Name == identifier || team.Key == identifier { 180 | return team.ID, nil 181 | } 182 | } 183 | 184 | // If no exact match, try case-insensitive match 185 | identifierLower := strings.ToLower(identifier) 186 | for _, team := range teams { 187 | if strings.ToLower(team.Name) == identifierLower || strings.ToLower(team.Key) == identifierLower { 188 | return team.ID, nil 189 | } 190 | } 191 | 192 | return "", fmt.Errorf("no team found with identifier '%s'", identifier) 193 | } 194 | 195 | // resolveProjectIdentifier resolves a project identifier (UUID, name, or slug) to a project ID 196 | func resolveProjectIdentifier(linearClient *linear.LinearClient, identifier string) (string, error) { 197 | // If it's a valid UUID, use it directly 198 | if isValidUUID(identifier) { 199 | return identifier, nil 200 | } 201 | 202 | // Otherwise, try to get the project by identifier (name or slug) 203 | project, err := linearClient.GetProject(identifier) 204 | if err != nil { 205 | return "", fmt.Errorf("failed to resolve project identifier '%s': %v", identifier, err) 206 | } 207 | 208 | return project.ID, nil 209 | } 210 | ``` -------------------------------------------------------------------------------- /memory-bank/techContext.md: -------------------------------------------------------------------------------- ```markdown 1 | # Technical Context: Linear MCP Server 2 | 3 | ## Technologies Used 4 | 5 | ### Programming Language 6 | - **Go**: Version 1.23.6 7 | - Chosen for its performance, strong typing, and concurrency support 8 | - Excellent standard library for HTTP requests and JSON handling 9 | 10 | ### Key Libraries 11 | 1. **github.com/mark3labs/mcp-go v0.8.5** 12 | - MCP protocol implementation for Go 13 | - Provides server, tool registration, and request/response handling 14 | 15 | 2. **github.com/spf13/cobra v1.9.1** 16 | - Command-line interface framework for Go 17 | - Provides subcommand support and flag handling 18 | 19 | 3. **gopkg.in/dnaeon/go-vcr.v4 v4.0.2** 20 | - HTTP interaction recording and playback for testing 21 | - Allows tests to run without actual API calls 22 | 23 | ### APIs 24 | - **Linear API** 25 | - REST API for Linear issue tracking system 26 | - Requires API key authentication 27 | - Has rate limiting constraints 28 | 29 | ## Development Setup 30 | 31 | ### Prerequisites 32 | - Go 1.23 or higher 33 | - Linear API key 34 | 35 | ### Environment Variables 36 | - `LINEAR_API_KEY`: Required for authentication with Linear API 37 | 38 | ### Build Process 39 | ```bash 40 | # Build the server 41 | go build 42 | 43 | # Run the server in read-only mode (default) 44 | ./linear-mcp-go server 45 | 46 | # Run the server with write operations enabled 47 | ./linear-mcp-go server --write-access 48 | 49 | # Set up for Cline (default) 50 | ./linear-mcp-go setup --api-key=your_linear_api_key 51 | 52 | # Set up with write access enabled 53 | ./linear-mcp-go setup --api-key=your_linear_api_key --write-access 54 | ``` 55 | 56 | ### Command-Line Structure 57 | - **Root Command**: Base command for the application 58 | - **Subcommands**: 59 | - `server`: Starts the Linear MCP server 60 | - `setup`: Sets up the Linear MCP server for use with an AI assistant 61 | 62 | ### Command-Line Flags 63 | - **Server Command**: 64 | - `--write-access`: Controls whether write operations are enabled (default: false) 65 | - When false, write tools (`linear_create_issue`, `linear_update_issue`, `linear_add_comment`) are disabled 66 | - When true, all tools are available 67 | 68 | - **Setup Command**: 69 | - `--api-key`: Linear API key (required) 70 | - `--tool`: The AI assistant tool to set up for (default: cline) 71 | - `--write-access`: Enable write operations (default: false) 72 | 73 | ### Testing 74 | ```bash 75 | # Run tests with existing recordings 76 | go test -v ./... 77 | 78 | # Re-record tests (requires LINEAR_API_KEY) 79 | go test -v -record=true ./... 80 | 81 | # Re-record all tests including state-changing ones 82 | go test -v -recordWrites=true ./... 83 | ``` 84 | 85 | ## Technical Constraints 86 | 87 | ### Linear API Limitations 88 | 1. **Rate Limiting** 89 | - Linear API has rate limits that must be respected 90 | - The server implements rate limiting to prevent quota exhaustion 91 | 92 | 2. **Authentication** 93 | - Requires API key passed via environment variable 94 | - No support for OAuth or other authentication methods 95 | 96 | ### MCP Protocol Constraints 97 | 1. **Communication Channel** 98 | - MCP server communicates via stdin/stdout 99 | - No HTTP or other network protocols for MCP communication 100 | 101 | 2. **Tool Schema** 102 | - Tools must define their parameters using MCP schema format 103 | - Parameters can be required or optional with descriptions 104 | 105 | ### Deployment Constraints 106 | 1. **Binary Distribution** 107 | - Server is distributed as a compiled binary 108 | - Binaries should be available for major platforms (Linux, macOS, Windows) 109 | 110 | 2. **Environment** 111 | - Requires environment variables to be set 112 | - No configuration file support currently 113 | 114 | ## Dependencies 115 | 116 | ### Direct Dependencies 117 | ``` 118 | github.com/mark3labs/mcp-go v0.8.5 119 | github.com/spf13/cobra v1.9.1 120 | gopkg.in/dnaeon/go-vcr.v4 v4.0.2 121 | ``` 122 | 123 | ### Indirect Dependencies 124 | ``` 125 | github.com/google/go-cmp v0.7.0 126 | github.com/google/uuid v1.6.0 127 | github.com/inconshreveable/mousetrap v1.1.0 128 | github.com/spf13/pflag v1.0.6 129 | gopkg.in/yaml.v3 v3.0.1 130 | ``` 131 | 132 | ## Version Information 133 | - **Server Version**: 1.0.0 (defined in pkg/server/server.go) 134 | - **Go Version**: 1.23.6 (defined in go.mod) 135 | - **MCP SDK Version**: 0.8.5 136 | 137 | ## Build and Release Process 138 | - GitHub Actions workflow for automated testing and releases 139 | - Releases are created when tags matching "v*" are pushed 140 | - Binaries are built for Linux, macOS, and Windows 141 | 142 | ## Code Style Guidelines 143 | 144 | ### Go Code Standards 145 | - **Formatting**: Use standard Go formatting (gofmt) - all code must be properly formatted 146 | - **Error Handling**: Follow Go best practices - return errors, don't panic in normal operation 147 | - **Naming Conventions**: 148 | - Use descriptive variable and function names that clearly indicate purpose 149 | - Follow Go naming conventions (camelCase for private, PascalCase for public) 150 | - **Documentation**: Add comments for all exported functions and types 151 | - **Code Organization**: Group related functionality together, separate concerns clearly 152 | 153 | ### Testing Patterns 154 | 155 | #### go-vcr Usage 156 | - **Test Fixtures**: All HTTP interactions are recorded using go-vcr 157 | - **Fixture Storage**: Test fixtures stored in `testdata/fixtures/` directory 158 | - **Replay Mode**: Tests run without Linear API key using recorded fixtures 159 | - **Recording Flags**: 160 | - `-record=true`: Re-record tests (requires LINEAR_API_KEY) 161 | - `-recordWrites=true`: Re-record all tests including state-changing operations 162 | 163 | #### Test Organization 164 | - Each tool handler has comprehensive test coverage 165 | - Test cases cover both success and error scenarios 166 | - Golden files in `testdata/golden/` contain expected output 167 | - Tests can run in isolation without external dependencies 168 | 169 | ### Development Commands 170 | 171 | #### Building 172 | ```bash 173 | # Build the server 174 | go build 175 | 176 | # Build with specific output name 177 | go build -o linear-mcp-server 178 | ``` 179 | 180 | #### Testing 181 | ```bash 182 | # Run tests with existing recordings 183 | go test -v ./... 184 | 185 | # Re-record tests (requires LINEAR_API_KEY) 186 | go test -v -record=true ./... 187 | 188 | # Re-record all tests including state-changing ones 189 | go test -v -recordWrites=true ./... 190 | 191 | # Run specific test 192 | go test -v -run TestSpecificFunction ./... 193 | ``` 194 | 195 | #### Running 196 | ```bash 197 | # Run server in read-only mode (default) 198 | ./linear-mcp-go server 199 | 200 | # Run server with write operations enabled 201 | ./linear-mcp-go server --write-access 202 | 203 | # Set up for AI assistant 204 | ./linear-mcp-go setup --api-key=your_linear_api_key 205 | ``` 206 | 207 | ## Future Technical Considerations 208 | 1. **Configuration File Support** 209 | - Could add support for configuration files instead of just environment variables 210 | 211 | 2. **Additional Linear API Features** 212 | - More Linear API endpoints could be exposed as MCP tools 213 | 214 | 3. **Improved Error Handling** 215 | - More detailed error messages and recovery strategies 216 | 217 | 4. **Metrics and Logging** 218 | - Add structured logging and metrics collection 219 | 220 | 5. **Rate Limiting Enhancements** 221 | - Make rate limits configurable 222 | - Add more sophisticated rate limiting strategies 223 | 224 | 6. **Authentication Methods** 225 | - Support for OAuth or other authentication methods beyond API keys 226 | ``` -------------------------------------------------------------------------------- /docs/design/001-mcp-go-upgrade.md: -------------------------------------------------------------------------------- ```markdown 1 | # Design Doc: `mcp-go` Library Upgrade 2 | 3 | **Author:** Cline 4 | **Date:** 2025-06-28 5 | **Status:** Proposed 6 | 7 | ## 1. Abstract 8 | 9 | This document outlines the plan to upgrade the `mcp-go` dependency from its current version (`v0.18.0`) to the latest version provided. The upgrade is necessary to leverage new features, performance improvements, and bug fixes in the library. This document details the scope, identifies breaking changes, and provides a phased implementation plan to ensure a smooth and successful migration. 10 | 11 | ## 2. Background and Motivation 12 | 13 | The Linear MCP Server currently relies on `mcp-go v0.18.0`. A new version of the library is available and has been cloned to `context/mcp-go` for reference. Analysis of the new version reveals significant API improvements and breaking changes. Upgrading will align the project with the latest MCP specification, improve developer experience through a more robust API, and ensure long-term maintainability. 14 | 15 | ## 3. Key Breaking Changes 16 | 17 | Analysis of the new `mcp-go` library reveals three primary areas of breaking changes that will drive the refactoring effort. 18 | 19 | ### 3.1. Tool Definition and Schema 20 | 21 | The API for defining tool schemas has been completely redesigned from a monolithic `WithInputSchema` method to a more granular, fluent builder pattern. 22 | 23 | - **Old Approach:** A single `WithInputSchema` call with a nested `mcp.Object` map. 24 | - **New Approach:** A series of top-level builder functions (`mcp.WithString`, `mcp.WithNumber`, etc.) are passed directly to `mcp.NewTool`. Property attributes like descriptions and requirement constraints are now handled by `PropertyOption` functions (`mcp.Description`, `mcp.Required`). 25 | 26 | ### 3.2. Tool Argument Parsing 27 | 28 | The new `mcp.CallToolRequest` struct introduces a suite of type-safe methods for accessing tool arguments, deprecating the need for manual map access and type assertions. 29 | 30 | - **Old Approach:** `request.GetArguments()` returned a `map[string]any`, requiring developers to perform manual key lookups and type assertions. 31 | - **New Approach:** Methods like `request.RequireString("key")`, `request.GetInt("key", 0)`, and `request.BindArguments(&myStruct)` provide robust, type-safe access to arguments. 32 | 33 | ### 3.3. Tool Result Construction 34 | 35 | The mechanism for returning results from a tool handler has been fundamentally changed. The simple, single-type helper functions have been replaced by a more flexible, multi-content structure. 36 | 37 | - **Old Approach:** Helper functions like `mcp.NewToolResultText(...)` and `mcp.NewToolResultError(...)` were used to create the result. 38 | - **New Approach:** The `mcp.CallToolResult` struct now contains a `Content` slice. Successful results are returned by populating this slice with `mcp.Content` objects (e.g., `mcp.TextContent`). Errors are handled by populating the `Content` slice with an error message and setting the `IsError` boolean flag to `true`. 39 | 40 | ## 4. Proposed Implementation Plan 41 | 42 | The upgrade will be performed in four distinct phases to ensure a controlled and verifiable migration. 43 | 44 | ### Phase 1: Dependency Update 45 | 46 | The first step is to instruct the Go compiler to use the new version of the library. 47 | 48 | 1. **Update `go.mod`:** A `replace` directive will be added to `go.mod` to point the `github.com/mark3labs/mcp-go` module to the local directory `context/mcp-go`. 49 | 2. **Tidy Dependencies:** `go mod tidy` will be executed to resolve the new dependency tree and remove unused old dependencies. 50 | 51 | ### Phase 2: Refactor Tool Definitions 52 | 53 | All tool registration sites will be updated to use the new schema definition API. 54 | 55 | 1. **Locate Tool Registrations:** All calls to `server.AddTool` will be identified. 56 | 2. **Rewrite Schemas:** The `WithInputSchema` calls will be replaced with the new fluent builder pattern (`mcp.WithString`, `mcp.WithNumber`, etc.). 57 | 58 | **Example Transformation:** 59 | 60 | ```go 61 | // OLD 62 | mcp.NewTool("get_issue"). 63 | WithDescription("..."). 64 | WithInputSchema(mcp.Object{ 65 | "issue": mcp.Required(mcp.String()).WithDescription("..."), 66 | }) 67 | 68 | // NEW 69 | mcp.NewTool("get_issue", 70 | mcp.WithDescription("..."), 71 | mcp.WithString("issue", 72 | mcp.Required(), 73 | mcp.Description("..."), 74 | ), 75 | ) 76 | ``` 77 | 78 | ### Phase 3: Refactor Tool Handlers 79 | 80 | This is the most significant phase, requiring changes to the logic within every tool handler. 81 | 82 | 1. **Update Argument Parsing:** All manual argument map access will be replaced with the new type-safe methods on `mcp.CallToolRequest`. 83 | 2. **Update Result Construction:** All `return` statements will be refactored to construct the `mcp.CallToolResult` struct with the appropriate `Content` slice and `IsError` flag. 84 | 85 | **Example Transformation (Success):** 86 | 87 | ```go 88 | // OLD 89 | return mcp.NewToolResultText("Success!"), nil 90 | 91 | // NEW 92 | return &mcp.CallToolResult{ 93 | Content: []mcp.Content{ 94 | mcp.TextContent{Type: "text", Text: "Success!"}, 95 | }, 96 | }, nil 97 | ``` 98 | 99 | **Example Transformation (Error):** 100 | 101 | ```go 102 | // OLD 103 | return mcp.NewToolResultError("Error message"), nil 104 | 105 | // NEW 106 | return &mcp.CallToolResult{ 107 | IsError: true, 108 | Content: []mcp.Content{ 109 | mcp.TextContent{Type: "text", Text: "Error message"}, 110 | }, 111 | }, nil 112 | ``` 113 | 114 | ### Phase 4: Compilation and Testing 115 | 116 | The final phase is to verify the correctness of the refactored code. 117 | 118 | 1. **Compile Project:** The entire project will be compiled to ensure there are no build errors. 119 | 2. **Update and Run Tests:** The existing test suite will be executed. It is anticipated that tests will fail due to the API changes. Test code, particularly mock object creation and result assertions, will be updated to align with the new library version. All tests must pass before the upgrade is considered complete. 120 | 121 | ## 5. Risks and Mitigation 122 | 123 | - **Risk:** Unforeseen breaking changes. 124 | - **Mitigation:** The phased approach allows for isolating and addressing issues systematically. The initial file analysis was thorough, but compilation will be the ultimate verification. 125 | - **Risk:** Logic errors introduced during refactoring. 126 | - **Mitigation:** The existing test suite provides a safety net. All tests will be run and updated to ensure existing functionality is preserved. 127 | 128 | ## 6. Success Criteria 129 | 130 | - The project successfully compiles against the new `mcp-go` library version. 131 | - All existing tests pass after being updated for the new API. 132 | - The server runs correctly and all tools function as expected. 133 | - The `go.mod` file correctly references the new library path. 134 | 135 | ## 7. Progress Tracking 136 | 137 | - [x] **Phase 1: Dependency Update** 138 | - [x] Update `go.mod` to `v0.32.0`. 139 | - [x] Run `go mod tidy`. 140 | - [x] **Phase 2: Refactor Tool Definitions** 141 | - [x] Refactor `linear_create_issue` 142 | - [x] Refactor `linear_update_issue` 143 | - [x] Refactor `linear_add_comment` 144 | - [x] Refactor `linear_get_issue` 145 | - [x] Refactor `linear_get_issue_comments` 146 | - [x] Refactor `linear_get_teams` 147 | - [x] Refactor `linear_get_user_issues` 148 | - [x] Refactor `linear_search_issues` 149 | - [x] **Phase 3: Refactor Tool Handlers** 150 | - [x] Refactor `createIssueHandler` 151 | - [x] Refactor `updateIssueHandler` 152 | - [x] Refactor `addCommentHandler` 153 | - [x] Refactor `getIssueHandler` 154 | - [x] Refactor `getIssueCommentsHandler` 155 | - [x] Refactor `getTeamsHandler` 156 | - [x] Refactor `getUserIssuesHandler` 157 | - [x] Refactor `searchIssuesHandler` 158 | - [x] **Phase 4: Compilation and Testing** 159 | - [x] Compile project successfully. 160 | - [x] Update and pass all tests. 161 | ``` -------------------------------------------------------------------------------- /docs/prd/002-tool-standardization.md: -------------------------------------------------------------------------------- ```markdown 1 | # Product Requirements Document: Linear MCP Server Tool Standardization 2 | 3 | ## Overview 4 | This document outlines the requirements for standardizing the Linear MCP Server tools according to a set of consistent rules. These rules aim to improve user experience, code maintainability, and consistency across all tools. 5 | 6 | ## Background 7 | The Linear MCP Server currently provides several tools for interacting with the Linear API through the Model Context Protocol (MCP). While these tools are functional, they lack consistency in their descriptions, parameter handling, and result formatting. This inconsistency can lead to confusion for users and maintenance challenges for developers. 8 | 9 | ## Requirements 10 | 11 | ### 1. Concise Tool Descriptions 12 | **Current State:** Tool descriptions are verbose and often contain parameter listings and result format explanations. 13 | 14 | **Requirement:** Tool descriptions should be concise and focus only on the tool's purpose and functionality. They should not: 15 | - List parameters (these are already defined in the schema) 16 | - Explain the result format (this should be consistent across tools) 17 | 18 | ### 2. Flexible Object Identifier Resolution 19 | **Current State:** Some tools (like `create_issue`) already support resolving different types of identifiers (UUID, name, key) to the underlying UUID, but this is not consistent across all tools. 20 | 21 | **Requirement:** All input arguments that reference Linear objects should: 22 | - Accept multiple forms of identification (UUID, name, key) 23 | - Resolve these identifiers to the underlying UUID using appropriate resolution functions 24 | - Use consistent resolution methods across all tools 25 | 26 | ### 3. Consistent Entity Rendering 27 | **Current State:** Entity rendering in results varies across tools, with inconsistent formatting and information hierarchy. 28 | 29 | **Requirement:** Tools fetching the same entities should: 30 | - Emit results using the same format 31 | - Add any tool-specific additional fields at the bottom of the result 32 | - Use code reuse between different tools to ensure consistency 33 | 34 | This requirement has two distinct parts: 35 | 36 | 1. **Full Entity Rendering**: 37 | - When displaying an entity as the primary subject of a response, use a consistent format with all required fields 38 | - Implement shared formatting functions (e.g., `formatIssue`, `formatTeam`) that include all necessary information 39 | - Example: When getting an issue with `linear_get_issue`, display the full issue details 40 | 41 | 2. **Entity Identifier Rendering**: 42 | - When referencing an entity from another entity, use a consistent, concise identifier format 43 | - Implement shared identifier formatting functions (e.g., `formatIssueIdentifier`, `formatTeamIdentifier`) 44 | - Always display entity identifiers in the format: "[Most descriptive field] (UUID: ...)" 45 | - For issues: "Issue: TEST-10 (UUID: ...)" 46 | - For teams: "Team: Test Team (UUID: ...)" 47 | 48 | ### 4. Field Superset for Retrieval Methods 49 | **Current State:** Retrieval methods may not include all fields that can be set through create and update methods. 50 | 51 | **Requirement:** The fields rendered on retrieval methods should follow these rules: 52 | 53 | 1. **Detail Retrieval Methods** (e.g., `linear_get_issue`): 54 | - Must include the complete superset of all fields that can be set through create and update methods 55 | - Ensures users can always view any field they can modify 56 | - Prevents "hidden" fields that can be set but not retrieved 57 | 58 | 2. **Overview Retrieval Methods** (e.g., `linear_search_issues`, `linear_get_user_issues`): 59 | - Only need to include key metadata fields (ID, title, status, priority, etc.) 60 | - Not required to display full content like descriptions or comments 61 | - Focus on providing sufficient information for selection and identification 62 | 63 | This distinction ensures each tool provides an appropriate level of detail for its purpose while maintaining consistency. 64 | 65 | For example: 66 | - `linear_get_issue` must include all fields from `linear_create_issue` and `linear_update_issue` 67 | - `linear_search_issues` only needs to show metadata fields, not full descriptions or comments 68 | 69 | ## Implementation Plan 70 | 71 | ### Phase 1: Analysis and Documentation 72 | 1. Review all existing tools to identify: 73 | - Current description formats 74 | - Object identifier resolution methods 75 | - Entity rendering patterns 76 | 77 | 2. Create a detailed tracking table for all tools, documenting: 78 | - Current state for each rule 79 | - Required changes 80 | - Implementation status 81 | 82 | ### Phase 2: Implementation 83 | 1. Create shared utility functions for: 84 | - Entity rendering 85 | - Identifier resolution (extending existing functions as needed) 86 | 87 | 2. Update each tool to: 88 | - Revise descriptions to be concise 89 | - Use shared identifier resolution functions 90 | - Implement consistent entity rendering 91 | 92 | 3. Update tests to verify: 93 | - Identifier resolution works correctly 94 | - Entity rendering is consistent 95 | 96 | ### Phase 3: Validation and Documentation 97 | 1. Verify all tools meet the new standards 98 | 2. Update documentation to reflect the changes 99 | 3. Create examples demonstrating the new consistent behavior 100 | 101 | ## Tool Standardization Tracking 102 | 103 | | Tool | Rule 1: Concise Description | Rule 2: Flexible Identifiers | Rule 3: Consistent Rendering | Status | 104 | |------|----------------------------|------------------------------|------------------------------|--------| 105 | | linear_create_issue | ❌ Too verbose | ✅ Supports team, parent issue, and label resolution | ❌ Custom format | Not Started | 106 | | linear_update_issue | ❌ Too verbose | ❌ Only accepts issue ID | ❌ Custom format | Not Started | 107 | | linear_search_issues | ❌ Too verbose | ❌ Only accepts teamId | ❌ Custom format | Not Started | 108 | | linear_get_user_issues | ❌ Too verbose | ❌ Only accepts userId | ❌ Custom format | Not Started | 109 | | linear_get_issue | ❌ Too verbose | ❌ Only accepts issueId | ❌ Custom format | Not Started | 110 | | linear_add_comment | ❌ Too verbose | ❌ Only accepts issueId | ❌ Custom format | Not Started | 111 | | linear_get_teams | ❌ Too verbose | ✅ No identifiers needed | ❌ Custom format | Not Started | 112 | 113 | ## Implementation Details 114 | 115 | ### Common Identifier Resolution Functions 116 | We'll need to create or extend the following resolution functions: 117 | 1. `resolveTeamIdentifier` (already exists) 118 | 2. `resolveIssueIdentifier` (extend from `resolveParentIssueIdentifier`) 119 | 3. `resolveUserIdentifier` (new) 120 | 4. `resolveLabelIdentifiers` (already exists) 121 | 122 | ### Common Entity Rendering Functions 123 | We'll need to create the following rendering functions: 124 | 1. `formatIssue` - For consistent issue rendering 125 | 2. `formatTeam` - For consistent team rendering 126 | 3. `formatUser` - For consistent user rendering 127 | 4. `formatComment` - For consistent comment rendering 128 | 129 | ### Code Structure Changes 130 | 1. Move common functions to a shared package 131 | 2. Create a new `rendering.go` file for entity formatting functions 132 | 3. Update all tool handlers to use these shared functions 133 | 134 | ## Success Criteria 135 | 1. All tool descriptions are concise and focused on functionality 136 | 2. All tools that reference Linear objects accept multiple identifier types 137 | 3. All tools render entities in a consistent format 138 | 4. Code reuse is maximized through shared functions 139 | 5. All tests pass with the new implementation 140 | 141 | ## Next Steps 142 | 1. Begin with updating one tool to serve as a reference implementation 143 | 2. Review the reference implementation to ensure it meets all requirements 144 | 3. Apply the same patterns to all remaining tools 145 | 4. Update tests and documentation 146 | 147 | ## Conclusion 148 | Implementing these standardization rules will improve the user experience, make the codebase more maintainable, and ensure consistency across all tools. This will make the Linear MCP Server more professional and easier to use. 149 | ```