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