linear-toon-mcp 0.0.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3832b203213cea96ab6f1a9bf4cb84599cda2d787ade784c7f823e121278c6a9
4
- data.tar.gz: a9b7f72796a1511eb15c828c1e4725af6926b3a40b568beca402512b97f21e58
3
+ metadata.gz: 6b25cc1890ddb1f779b8adcd4636e6d4415035c671e91f5da91f5df78d5b03e6
4
+ data.tar.gz: ee755802e2a8ab10e7008c0a21779fe1a4058a29ca117d2c56cb9d8761620854
5
5
  SHA512:
6
- metadata.gz: b3d8fd5d7acaf9abda70b2c612a425eca4af435331a2088669f89564f28fb4166e74eecbbc54607a027b0e97dbdc0ee7abdfe026a22839a98030654b4fe0ac16
7
- data.tar.gz: f218f5f454747918fc0a522cc91622f2c899edc52af3f097da95fcbabf0aeac5a9c4e8e49c2afd0cc8686a3a2d1c943fb2a055939392b14e2e7575f8b920137b
6
+ metadata.gz: cef723735b39f80756443f1a680816633f9a7c1605d68f7a4c4fd177d37c754e3ec5a9328e5c868c726f1d47429ea9ee6f7221aab4a74f917ecb271b67717270
7
+ data.tar.gz: 2032d28b1e54dc569a8dccd96e06544056d1c06168e4170d2c1f3e7f1b6c5e7c08a1b4881ca0a546349a97329175f2516e883bcecdf52555b55a1447538cfe60
data/README.md CHANGED
@@ -36,21 +36,20 @@ Get your API key from [Linear Settings > API](https://linear.app/settings/api).
36
36
 
37
37
  ### Claude Code
38
38
 
39
- Add to your MCP config (`.mcp.json` or Claude Code settings):
40
-
41
- ```json
42
- {
43
- "mcpServers": {
44
- "linear": {
45
- "command": "linear-toon-mcp",
46
- "env": {
47
- "LINEAR_API_KEY": "lin_api_xxxxx"
48
- }
49
- }
50
- }
51
- }
39
+ ```bash
40
+ claude mcp add -e LINEAR_API_KEY=lin_api_xxxxx linear-toon -- linear-toon-mcp
52
41
  ```
53
42
 
43
+ ## Tools
44
+
45
+ | Tool | Description |
46
+ |------|-------------|
47
+ | `get_issue` | Retrieve a Linear issue by ID or identifier (e.g., `LIN-123`). Returns issue details including title, description, state, assignee, labels, project, and attachments. |
48
+ | `list_issues` | List issues with optional filters (team, assignee, state, label, priority, project, cycle) and cursor-based pagination. Supports name or UUID for most filters. |
49
+ | `create_issue` | Create a new Linear issue. Accepts human-friendly names for team, assignee, state, labels, project, cycle, and milestone (resolved to IDs automatically). Supports issue relations and link attachments. |
50
+ | `update_issue` | Update an existing Linear issue by ID. Supports partial updates, null to remove fields, and relation replacement. |
51
+ | `create_comment` | Create a comment on a Linear issue. Supports Markdown content and threaded replies via parentId. |
52
+
54
53
  ## Development
55
54
 
56
55
  ```bash
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "json"
5
+ require "uri"
6
+
7
+ module LinearToonMcp
8
+ # Minimal HTTP client for Linear's GraphQL API.
9
+ class Client
10
+ ENDPOINT = URI("https://api.linear.app/graphql").freeze
11
+
12
+ # @param api_key [String] Linear API key (defaults to +LINEAR_API_KEY+ env var)
13
+ # @raise [ArgumentError] when API key is nil or empty
14
+ def initialize(api_key: ENV["LINEAR_API_KEY"])
15
+ raise ArgumentError, "LINEAR_API_KEY is required" if api_key.nil? || api_key.empty?
16
+
17
+ @api_key = api_key
18
+ end
19
+
20
+ # Execute a GraphQL query against Linear API.
21
+ # @param query_string [String] GraphQL query
22
+ # @param variables [Hash] query variables
23
+ # @return [Hash] the +data+ key from the GraphQL response
24
+ # @raise [Error] on HTTP errors, GraphQL errors, or empty responses
25
+ def query(query_string, variables: {})
26
+ response = post(query_string, variables)
27
+
28
+ body = begin
29
+ JSON.parse(response.body)
30
+ rescue JSON::ParserError
31
+ nil
32
+ end
33
+
34
+ unless response.is_a?(Net::HTTPSuccess)
35
+ messages = body&.dig("errors")&.map { |e| e["message"] }&.join("; ")
36
+ raise Error, "HTTP #{response.code}: #{messages || response.body}"
37
+ end
38
+
39
+ raise Error, "Empty response from Linear API" unless body
40
+
41
+ if body["errors"]&.any?
42
+ messages = body["errors"].map { |e| e["message"] }.join("; ")
43
+ raise Error, "GraphQL error: #{messages}"
44
+ end
45
+
46
+ body["data"]
47
+ end
48
+
49
+ private
50
+
51
+ def post(query_string, variables)
52
+ request = Net::HTTP::Post.new(ENDPOINT)
53
+ request["Content-Type"] = "application/json"
54
+ request["Authorization"] = @api_key
55
+ request.body = JSON.generate(query: query_string, variables:)
56
+
57
+ Net::HTTP.start(ENDPOINT.host, ENDPOINT.port, use_ssl: true) do |http|
58
+ http.request(request)
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,152 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LinearToonMcp
4
+ # Shared resolvers for converting human-friendly names to Linear API UUIDs.
5
+ # Each resolver passes through UUIDs unchanged and performs a GraphQL lookup otherwise.
6
+ module Resolvers
7
+ UUID_RE = /\A\h{8}-\h{4}-\h{4}-\h{4}-\h{12}\z/
8
+ NUMERIC_RE = /\A\d+\z/
9
+
10
+ TEAM_QUERY = <<~GRAPHQL
11
+ query($filter: TeamFilter) {
12
+ teams(filter: $filter, first: 1) { nodes { id } }
13
+ }
14
+ GRAPHQL
15
+
16
+ VIEWER_QUERY = "query { viewer { id } }"
17
+
18
+ USER_QUERY = <<~GRAPHQL
19
+ query($filter: UserFilter) {
20
+ users(filter: $filter, first: 1) { nodes { id } }
21
+ }
22
+ GRAPHQL
23
+
24
+ STATE_QUERY = <<~GRAPHQL
25
+ query($filter: WorkflowStateFilter) {
26
+ workflowStates(filter: $filter, first: 1) { nodes { id } }
27
+ }
28
+ GRAPHQL
29
+
30
+ LABEL_QUERY = <<~GRAPHQL
31
+ query($filter: IssueLabelFilter) {
32
+ issueLabels(filter: $filter, first: 1) { nodes { id } }
33
+ }
34
+ GRAPHQL
35
+
36
+ PROJECT_QUERY = <<~GRAPHQL
37
+ query($filter: ProjectFilter) {
38
+ projects(filter: $filter, first: 1) { nodes { id } }
39
+ }
40
+ GRAPHQL
41
+
42
+ CYCLE_QUERY = <<~GRAPHQL
43
+ query($filter: CycleFilter) {
44
+ cycles(filter: $filter, first: 1) { nodes { id } }
45
+ }
46
+ GRAPHQL
47
+
48
+ MILESTONE_QUERY = <<~GRAPHQL
49
+ query($filter: ProjectMilestoneFilter) {
50
+ projectMilestones(filter: $filter, first: 1) { nodes { id } }
51
+ }
52
+ GRAPHQL
53
+
54
+ module_function
55
+
56
+ # @param client [Client]
57
+ # @param value [String] team UUID or name
58
+ # @return [String] team UUID
59
+ # @raise [Error] when team not found
60
+ def resolve_team(client, value)
61
+ return value if value.match?(UUID_RE)
62
+ data = client.query(TEAM_QUERY, variables: {filter: {name: {eqIgnoreCase: value}}})
63
+ data.dig("teams", "nodes", 0, "id") or raise Error, "Team not found: #{value}"
64
+ end
65
+
66
+ # @param client [Client]
67
+ # @param value [String] user UUID, "me", email, or name
68
+ # @return [String] user UUID
69
+ # @raise [Error] when user not found
70
+ def resolve_user(client, value)
71
+ return value if value.match?(UUID_RE)
72
+
73
+ if value == "me"
74
+ data = client.query(VIEWER_QUERY)
75
+ return data.dig("viewer", "id") || raise(Error, "Could not resolve current user")
76
+ end
77
+
78
+ filter = value.include?("@") ? {email: {eq: value}} : {name: {eqIgnoreCase: value}}
79
+ data = client.query(USER_QUERY, variables: {filter:})
80
+ data.dig("users", "nodes", 0, "id") or raise Error, "User not found: #{value}"
81
+ end
82
+
83
+ # @param client [Client]
84
+ # @param team_id [String] team UUID (for scoping)
85
+ # @param value [String] state UUID or name
86
+ # @return [String] state UUID
87
+ # @raise [Error] when state not found
88
+ def resolve_state(client, team_id, value)
89
+ return value if value.match?(UUID_RE)
90
+ filter = {name: {eqIgnoreCase: value}, team: {id: {eq: team_id}}}
91
+ data = client.query(STATE_QUERY, variables: {filter:})
92
+ data.dig("workflowStates", "nodes", 0, "id") or raise Error, "State not found: #{value}"
93
+ end
94
+
95
+ # @param client [Client]
96
+ # @param value [String] label UUID or name
97
+ # @return [String] label UUID
98
+ # @raise [Error] when label not found
99
+ def resolve_label(client, value)
100
+ return value if value.match?(UUID_RE)
101
+ data = client.query(LABEL_QUERY, variables: {filter: {name: {eqIgnoreCase: value}}})
102
+ data.dig("issueLabels", "nodes", 0, "id") or raise Error, "Label not found: #{value}"
103
+ end
104
+
105
+ # @param client [Client]
106
+ # @param values [Array<String>] label UUIDs or names
107
+ # @return [Array<String>] label UUIDs
108
+ def resolve_labels(client, values)
109
+ values.map { |v| resolve_label(client, v) }
110
+ end
111
+
112
+ # @param client [Client]
113
+ # @param value [String] project UUID or name
114
+ # @return [String] project UUID
115
+ # @raise [Error] when project not found
116
+ def resolve_project(client, value)
117
+ return value if value.match?(UUID_RE)
118
+ data = client.query(PROJECT_QUERY, variables: {filter: {name: {eqIgnoreCase: value}}})
119
+ data.dig("projects", "nodes", 0, "id") or raise Error, "Project not found: #{value}"
120
+ end
121
+
122
+ # @param client [Client]
123
+ # @param team_id [String] team UUID (for scoping)
124
+ # @param value [String] cycle UUID, number, or name
125
+ # @return [String] cycle UUID
126
+ # @raise [Error] when cycle not found
127
+ def resolve_cycle(client, team_id, value)
128
+ return value if value.match?(UUID_RE)
129
+
130
+ filter = if value.match?(NUMERIC_RE)
131
+ {number: {eq: value.to_i}, team: {id: {eq: team_id}}}
132
+ else
133
+ {name: {eqIgnoreCase: value}, team: {id: {eq: team_id}}}
134
+ end
135
+
136
+ data = client.query(CYCLE_QUERY, variables: {filter:})
137
+ data.dig("cycles", "nodes", 0, "id") or raise Error, "Cycle not found: #{value}"
138
+ end
139
+
140
+ # @param client [Client]
141
+ # @param project_id [String] project UUID (for scoping)
142
+ # @param value [String] milestone UUID or name
143
+ # @return [String] milestone UUID
144
+ # @raise [Error] when milestone not found
145
+ def resolve_milestone(client, project_id, value)
146
+ return value if value.match?(UUID_RE)
147
+ filter = {name: {eqIgnoreCase: value}, project: {id: {eq: project_id}}}
148
+ data = client.query(MILESTONE_QUERY, variables: {filter:})
149
+ data.dig("projectMilestones", "nodes", 0, "id") or raise Error, "Milestone not found: #{value}"
150
+ end
151
+ end
152
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "toon"
4
+
5
+ module LinearToonMcp
6
+ module Tools
7
+ # Create a comment on a Linear issue. Supports Markdown content
8
+ # and threaded replies via parentId.
9
+ class CreateComment < MCP::Tool
10
+ description "Create a comment on a Linear issue"
11
+
12
+ annotations(
13
+ read_only_hint: false,
14
+ destructive_hint: false,
15
+ idempotent_hint: false
16
+ )
17
+
18
+ input_schema(
19
+ properties: {
20
+ issueId: {type: "string", description: "Issue ID"},
21
+ body: {type: "string", description: "Content as Markdown"},
22
+ parentId: {type: "string", description: "Parent comment ID (for replies)"}
23
+ },
24
+ required: ["issueId", "body"],
25
+ additionalProperties: false
26
+ )
27
+
28
+ MUTATION = <<~GRAPHQL
29
+ mutation($input: CommentCreateInput!) {
30
+ commentCreate(input: $input) {
31
+ success
32
+ comment {
33
+ id
34
+ body
35
+ createdAt
36
+ user { id name }
37
+ issue { id identifier }
38
+ }
39
+ }
40
+ }
41
+ GRAPHQL
42
+
43
+ # standard:disable Naming/VariableName
44
+ class << self
45
+ # @param issueId [String] Linear issue ID
46
+ # @param body [String] comment content as Markdown
47
+ # @param parentId [String, nil] parent comment ID for threaded replies
48
+ # @param server_context [Hash, nil] must contain +:client+ key with a {Client}
49
+ # @return [MCP::Tool::Response] TOON-encoded comment or error
50
+ def call(issueId:, body:, parentId: nil, server_context: nil)
51
+ client = server_context&.dig(:client) or raise Error, "client missing from server_context"
52
+
53
+ input = {issueId:, body:}
54
+ input[:parentId] = parentId if parentId
55
+
56
+ data = client.query(MUTATION, variables: {input:})
57
+ result = data["commentCreate"]
58
+ raise Error, "Comment creation failed" unless result["success"]
59
+
60
+ text = Toon.encode(result["comment"])
61
+ MCP::Tool::Response.new([{type: "text", text:}])
62
+ rescue Error => e
63
+ MCP::Tool::Response.new([{type: "text", text: e.message}], error: true)
64
+ end
65
+ end
66
+ # standard:enable Naming/VariableName
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,157 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "toon"
4
+
5
+ module LinearToonMcp
6
+ module Tools
7
+ # Create a new Linear issue with full parameter support.
8
+ # Resolves human-friendly names to IDs for team, assignee, state, labels,
9
+ # project, cycle, and milestone. Supports post-mutation relations and links.
10
+ class CreateIssue < MCP::Tool
11
+ description "Create a new Linear issue"
12
+
13
+ annotations(
14
+ read_only_hint: false,
15
+ destructive_hint: false,
16
+ idempotent_hint: false
17
+ )
18
+
19
+ # standard:disable Layout/LineLength
20
+ input_schema(
21
+ properties: {
22
+ title: {type: "string", description: "Issue title"},
23
+ team: {type: "string", description: "Team name or ID"},
24
+ description: {type: "string", description: "Content as Markdown"},
25
+ assignee: {type: "string", description: 'User ID, name, email, or "me"'},
26
+ priority: {type: "number", description: "0=None, 1=Urgent, 2=High, 3=Normal, 4=Low"},
27
+ state: {type: "string", description: "State name or ID"},
28
+ labels: {type: "array", items: {type: "string"}, description: "Label names or IDs"},
29
+ project: {type: "string", description: "Project name or ID"},
30
+ cycle: {type: "string", description: "Cycle name, number, or ID"},
31
+ estimate: {type: "number", description: "Issue estimate value"},
32
+ dueDate: {type: "string", description: "Due date (ISO format)"},
33
+ parentId: {type: "string", description: "Parent issue ID"},
34
+ blockedBy: {type: "array", items: {type: "string"}, description: "Issue IDs/identifiers blocking this"},
35
+ blocks: {type: "array", items: {type: "string"}, description: "Issue IDs/identifiers this blocks"},
36
+ relatedTo: {type: "array", items: {type: "string"}, description: "Related issue IDs/identifiers"},
37
+ duplicateOf: {type: "string", description: "Duplicate of issue ID/identifier"},
38
+ milestone: {type: "string", description: "Milestone name or ID"},
39
+ delegate: {type: "string", description: "Agent name or ID"},
40
+ links: {type: "array", items: {type: "object", properties: {url: {type: "string"}, title: {type: "string"}}, required: ["url", "title"]}, description: "Link attachments [{url, title}]"}
41
+ },
42
+ required: ["title", "team"],
43
+ additionalProperties: false
44
+ )
45
+ # standard:enable Layout/LineLength
46
+
47
+ MUTATION = <<~GRAPHQL
48
+ mutation($input: IssueCreateInput!) {
49
+ issueCreate(input: $input) {
50
+ success
51
+ issue {
52
+ id identifier title url
53
+ state { name }
54
+ assignee { id name }
55
+ team { id name }
56
+ labels { nodes { name } }
57
+ project { id name }
58
+ }
59
+ }
60
+ }
61
+ GRAPHQL
62
+
63
+ RELATION_MUTATION = <<~GRAPHQL
64
+ mutation($input: IssueRelationCreateInput!) {
65
+ issueRelationCreate(input: $input) { success }
66
+ }
67
+ GRAPHQL
68
+
69
+ LINK_MUTATION = <<~GRAPHQL
70
+ mutation($url: String!, $issueId: String!, $title: String) {
71
+ attachmentLinkURL(url: $url, issueId: $issueId, title: $title) { success }
72
+ }
73
+ GRAPHQL
74
+
75
+ # standard:disable Naming/VariableName, Metrics/MethodLength
76
+ class << self
77
+ # @param title [String] issue title
78
+ # @param team [String] team name or UUID
79
+ # @param server_context [Hash, nil] must contain +:client+ key
80
+ # @return [MCP::Tool::Response] TOON-encoded issue or error
81
+ def call(title:, team:, server_context: nil, **kwargs)
82
+ client = server_context&.dig(:client) or raise Error, "client missing from server_context"
83
+ raise Error, "Cannot specify both assignee and delegate" if kwargs.key?(:assignee) && kwargs.key?(:delegate)
84
+
85
+ team_id = Resolvers.resolve_team(client, team)
86
+ input = {title:, teamId: team_id}
87
+
88
+ add_direct_fields(input, **kwargs)
89
+ resolve_fields(input, client, team_id, **kwargs)
90
+
91
+ data = client.query(MUTATION, variables: {input:})
92
+ result = data["issueCreate"]
93
+ raise Error, "Issue creation failed" unless result["success"]
94
+
95
+ issue = result["issue"]
96
+ create_relations(client, issue["id"], **kwargs)
97
+ create_links(client, issue["id"], kwargs[:links])
98
+
99
+ text = Toon.encode(issue)
100
+ MCP::Tool::Response.new([{type: "text", text:}])
101
+ rescue Error => e
102
+ MCP::Tool::Response.new([{type: "text", text: e.message}], error: true)
103
+ end
104
+
105
+ private
106
+
107
+ def add_direct_fields(input, description: nil, priority: nil, estimate: nil,
108
+ dueDate: nil, parentId: nil, **)
109
+ input[:description] = description if description
110
+ input[:priority] = priority if priority
111
+ input[:estimate] = estimate if estimate
112
+ input[:dueDate] = dueDate if dueDate
113
+ input[:parentId] = parentId if parentId
114
+ end
115
+
116
+ def resolve_fields(input, client, team_id, assignee: nil, state: nil, labels: nil,
117
+ project: nil, cycle: nil, milestone: nil, delegate: nil, **)
118
+ input[:assigneeId] = Resolvers.resolve_user(client, delegate || assignee) if assignee || delegate
119
+ input[:stateId] = Resolvers.resolve_state(client, team_id, state) if state
120
+ input[:labelIds] = Resolvers.resolve_labels(client, labels) if labels
121
+ project_id = Resolvers.resolve_project(client, project) if project
122
+ input[:projectId] = project_id if project_id
123
+ input[:cycleId] = Resolvers.resolve_cycle(client, team_id, cycle) if cycle
124
+ if milestone
125
+ raise Error, "milestone requires project" unless project_id
126
+ input[:projectMilestoneId] = Resolvers.resolve_milestone(client, project_id, milestone)
127
+ end
128
+ end
129
+
130
+ def create_relations(client, issue_id, blockedBy: nil, blocks: nil, relatedTo: nil, duplicateOf: nil, **)
131
+ Array(blockedBy).each { |id| create_relation(client, issue_id, id, "isBlockedBy") }
132
+ Array(blocks).each { |id| create_relation(client, issue_id, id, "blocks") }
133
+ Array(relatedTo).each { |id| create_relation(client, issue_id, id, "related") }
134
+ create_relation(client, issue_id, duplicateOf, "duplicate") if duplicateOf
135
+ end
136
+
137
+ def create_relation(client, issue_id, related_issue_id, type)
138
+ input = {issueId: issue_id, relatedIssueId: related_issue_id, type:}
139
+ data = client.query(RELATION_MUTATION, variables: {input:})
140
+ return if data.dig("issueRelationCreate", "success")
141
+ raise Error, "Failed to create #{type} relation with #{related_issue_id}"
142
+ end
143
+
144
+ def create_links(client, issue_id, links)
145
+ return unless links
146
+
147
+ links.each do |link|
148
+ data = client.query(LINK_MUTATION, variables: {url: link["url"], issueId: issue_id, title: link["title"]})
149
+ next if data.dig("attachmentLinkURL", "success")
150
+ raise Error, "Failed to attach link: #{link["url"]}"
151
+ end
152
+ end
153
+ end
154
+ # standard:enable Naming/VariableName, Metrics/MethodLength
155
+ end
156
+ end
157
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "toon"
4
+
5
+ module LinearToonMcp
6
+ module Tools
7
+ # Fetch a single Linear issue by ID or identifier and return it as TOON.
8
+ # Includes metadata, state, assignee, labels, project, team, and attachments.
9
+ class GetIssue < MCP::Tool
10
+ description "Retrieve a Linear issue by ID"
11
+
12
+ annotations(
13
+ read_only_hint: true,
14
+ destructive_hint: false,
15
+ idempotent_hint: true
16
+ )
17
+
18
+ input_schema(
19
+ properties: {
20
+ id: {type: "string", description: "Issue ID or identifier (e.g., LIN-123)"}
21
+ },
22
+ required: ["id"],
23
+ additionalProperties: false
24
+ )
25
+
26
+ QUERY = <<~GRAPHQL
27
+ query($id: String!) {
28
+ issue(id: $id) {
29
+ id
30
+ identifier
31
+ title
32
+ description
33
+ priority
34
+ priorityLabel
35
+ url
36
+ branchName
37
+ createdAt
38
+ updatedAt
39
+ archivedAt
40
+ completedAt
41
+ dueDate
42
+ state { name }
43
+ assignee { id name }
44
+ creator { id name }
45
+ labels { nodes { name } }
46
+ project { id name }
47
+ team { id name }
48
+ attachments { nodes { id title url } }
49
+ }
50
+ }
51
+ GRAPHQL
52
+
53
+ class << self
54
+ # @param id [String] Linear issue ID or identifier (e.g., "LIN-123")
55
+ # @param server_context [Hash, nil] must contain +:client+ key with a {Client}
56
+ # @return [MCP::Tool::Response] TOON-encoded issue or error
57
+ def call(id:, server_context: nil)
58
+ client = server_context&.dig(:client) or raise Error, "client missing from server_context"
59
+ data = client.query(QUERY, variables: {id:})
60
+ issue = data["issue"] or raise Error, "Issue not found: #{id}"
61
+ text = Toon.encode(issue)
62
+ MCP::Tool::Response.new([{type: "text", text:}])
63
+ rescue Error => e
64
+ MCP::Tool::Response.new([{type: "text", text: e.message}], error: true)
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,171 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "toon"
4
+
5
+ module LinearToonMcp
6
+ module Tools
7
+ # List Linear issues with optional filters and pagination.
8
+ # Returns TOON-encoded array with page info for cursor-based pagination.
9
+ class ListIssues < MCP::Tool
10
+ description "List issues with optional filters and pagination"
11
+
12
+ annotations(
13
+ read_only_hint: true,
14
+ destructive_hint: false,
15
+ idempotent_hint: true
16
+ )
17
+
18
+ input_schema(
19
+ properties: {
20
+ assignee: {type: "string", description: 'User ID, name, email, or "me"'},
21
+ createdAt: {type: "string", description: "Created after: ISO-8601 date/duration (e.g., -P1D)"},
22
+ cursor: {type: "string", description: "Next page cursor"},
23
+ cycle: {type: "string", description: "Cycle name, number, or ID"},
24
+ delegate: {type: "string", description: "Agent name or ID"},
25
+ includeArchived: {type: "boolean", description: "Include archived items (default true)"},
26
+ label: {type: "string", description: "Label name or ID"},
27
+ limit: {type: "integer", description: "Max results (default 50, max 250)"},
28
+ orderBy: {type: "string", description: "createdAt or updatedAt (default updatedAt)", enum: ["createdAt", "updatedAt"]},
29
+ parentId: {type: "string", description: "Parent issue ID"},
30
+ priority: {type: "integer", description: "0=None, 1=Urgent, 2=High, 3=Normal, 4=Low"},
31
+ project: {type: "string", description: "Project name or ID"},
32
+ query: {type: "string", description: "Search issue title or description"},
33
+ state: {type: "string", description: "State name or ID"},
34
+ team: {type: "string", description: "Team name or ID"},
35
+ updatedAt: {type: "string", description: "Updated after: ISO-8601 date/duration (e.g., -P1D)"}
36
+ },
37
+ additionalProperties: false
38
+ )
39
+
40
+ QUERY = <<~GRAPHQL
41
+ query($filter: IssueFilter, $first: Int, $after: String, $orderBy: PaginationOrderBy, $includeArchived: Boolean) {
42
+ issues(filter: $filter, first: $first, after: $after, orderBy: $orderBy, includeArchived: $includeArchived) {
43
+ nodes {
44
+ id
45
+ identifier
46
+ title
47
+ priority
48
+ priorityLabel
49
+ url
50
+ createdAt
51
+ updatedAt
52
+ state { name }
53
+ assignee { id name }
54
+ labels { nodes { name } }
55
+ project { id name }
56
+ team { id name }
57
+ }
58
+ pageInfo {
59
+ hasNextPage
60
+ endCursor
61
+ }
62
+ }
63
+ }
64
+ GRAPHQL
65
+
66
+ UUID_RE = Resolvers::UUID_RE
67
+ NUMERIC_RE = Resolvers::NUMERIC_RE
68
+
69
+ # standard:disable Naming/VariableName
70
+ class << self
71
+ # @param assignee [String, nil] user ID, name, email, or "me"
72
+ # @param createdAt [String, nil] ISO-8601 date or duration
73
+ # @param cursor [String, nil] pagination cursor
74
+ # @param cycle [String, nil] cycle name, number, or ID
75
+ # @param delegate [String, nil] agent name or ID
76
+ # @param includeArchived [Boolean, nil] include archived issues
77
+ # @param label [String, nil] label name or ID
78
+ # @param limit [Integer, nil] max results (default 50, max 250)
79
+ # @param orderBy [String, nil] "createdAt" or "updatedAt"
80
+ # @param parentId [String, nil] parent issue ID
81
+ # @param priority [Integer, nil] 0-4
82
+ # @param project [String, nil] project name or ID
83
+ # @param query [String, nil] search title or description
84
+ # @param state [String, nil] state name or ID
85
+ # @param team [String, nil] team name or ID
86
+ # @param updatedAt [String, nil] ISO-8601 date or duration
87
+ # @param server_context [Hash, nil] must contain +:client+ key with a {Client}
88
+ # @return [MCP::Tool::Response] TOON-encoded issue list or error
89
+ def call(assignee: nil, createdAt: nil, cursor: nil, cycle: nil,
90
+ delegate: nil, includeArchived: nil, label: nil, limit: nil, orderBy: nil,
91
+ parentId: nil, priority: nil, project: nil, query: nil, state: nil,
92
+ team: nil, updatedAt: nil, server_context: nil)
93
+ client = server_context&.dig(:client) or raise Error, "client missing from server_context"
94
+
95
+ filter = build_filter(
96
+ assignee:, team:, project:, state:, label:,
97
+ priority:, parentId:, cycle:, delegate:,
98
+ query:, createdAt:, updatedAt:
99
+ )
100
+
101
+ variables = {
102
+ first: (limit || 50).clamp(1, 250),
103
+ orderBy: orderBy || "updatedAt",
104
+ includeArchived: includeArchived != false
105
+ }
106
+ variables[:filter] = filter unless filter.empty?
107
+ variables[:after] = cursor if cursor
108
+
109
+ data = client.query(QUERY, variables:)
110
+ issues = data["issues"] or raise Error, "Unexpected response: missing issues field"
111
+ text = Toon.encode(issues)
112
+ MCP::Tool::Response.new([{type: "text", text:}])
113
+ rescue Error => e
114
+ MCP::Tool::Response.new([{type: "text", text: e.message}], error: true)
115
+ end
116
+
117
+ private
118
+
119
+ def build_filter(assignee:, team:, project:, state:, label:,
120
+ priority:, parentId:, cycle:, delegate:,
121
+ query:, createdAt:, updatedAt:)
122
+ filter = {}
123
+ filter[:assignee] = assignee_filter(assignee) if assignee
124
+ filter[:team] = name_or_id(team) if team
125
+ filter[:project] = name_or_id(project) if project
126
+ filter[:state] = name_or_id(state) if state
127
+ filter[:labels] = {some: name_or_id(label)} if label
128
+ filter[:priority] = {eq: priority} if priority
129
+ filter[:parent] = {id: {eq: parentId}} if parentId
130
+ filter[:cycle] = cycle_filter(cycle) if cycle
131
+ filter[:delegate] = name_or_id(delegate) if delegate
132
+ if query
133
+ filter[:or] = [
134
+ {title: {containsIgnoreCase: query}},
135
+ {description: {containsIgnoreCase: query}}
136
+ ]
137
+ end
138
+ filter[:createdAt] = {gte: resolve_date(createdAt)} if createdAt
139
+ filter[:updatedAt] = {gte: resolve_date(updatedAt)} if updatedAt
140
+ filter
141
+ end
142
+ # standard:enable Naming/VariableName
143
+
144
+ def assignee_filter(value)
145
+ return {isMe: {eq: true}} if value == "me"
146
+ return {id: {eq: value}} if value.match?(UUID_RE)
147
+ return {email: {eq: value}} if value.include?("@")
148
+ {name: {eqIgnoreCase: value}}
149
+ end
150
+
151
+ def name_or_id(value)
152
+ value.match?(UUID_RE) ? {id: {eq: value}} : {name: {eqIgnoreCase: value}}
153
+ end
154
+
155
+ def cycle_filter(value)
156
+ return {id: {eq: value}} if value.match?(UUID_RE)
157
+ return {number: {eq: value.to_i}} if value.match?(NUMERIC_RE)
158
+ {name: {eqIgnoreCase: value}}
159
+ end
160
+
161
+ def resolve_date(value)
162
+ return value unless value.start_with?("-P")
163
+ match = value.match(/\A-P(?:(\d+)Y)?(?:(\d+)M)?(?:(\d+)W)?(?:(\d+)D)?\z/)
164
+ raise Error, "Invalid duration: #{value}" unless match
165
+ days = match.captures.zip([365, 30, 7, 1]).sum { |c, m| (c&.to_i || 0) * m }
166
+ (Time.now.utc - (days * 86_400)).iso8601
167
+ end
168
+ end
169
+ end
170
+ end
171
+ end
@@ -0,0 +1,221 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "toon"
4
+
5
+ module LinearToonMcp
6
+ module Tools
7
+ # Update an existing Linear issue by ID. Supports partial updates,
8
+ # null to remove fields, and relation replacement semantics.
9
+ class UpdateIssue < MCP::Tool
10
+ description "Update an existing Linear issue"
11
+
12
+ annotations(
13
+ read_only_hint: false,
14
+ destructive_hint: false,
15
+ idempotent_hint: false
16
+ )
17
+
18
+ # standard:disable Layout/LineLength
19
+ input_schema(
20
+ properties: {
21
+ id: {type: "string", description: "Issue ID"},
22
+ title: {type: "string", description: "Issue title"},
23
+ team: {type: "string", description: "Team name or ID"},
24
+ description: {type: "string", description: "Content as Markdown"},
25
+ assignee: {type: ["string", "null"], description: 'User ID, name, email, or "me". Null to remove'},
26
+ priority: {type: "number", description: "0=None, 1=Urgent, 2=High, 3=Normal, 4=Low"},
27
+ state: {type: "string", description: "State name or ID"},
28
+ labels: {type: "array", items: {type: "string"}, description: "Label names or IDs"},
29
+ project: {type: "string", description: "Project name or ID"},
30
+ cycle: {type: "string", description: "Cycle name, number, or ID"},
31
+ estimate: {type: "number", description: "Issue estimate value"},
32
+ dueDate: {type: "string", description: "Due date (ISO format)"},
33
+ parentId: {type: ["string", "null"], description: "Parent issue ID. Null to remove"},
34
+ blockedBy: {type: "array", items: {type: "string"}, description: "Issue IDs blocking this. Replaces existing; omit to keep unchanged"},
35
+ blocks: {type: "array", items: {type: "string"}, description: "Issue IDs this blocks. Replaces existing; omit to keep unchanged"},
36
+ relatedTo: {type: "array", items: {type: "string"}, description: "Related issue IDs. Replaces existing; omit to keep unchanged"},
37
+ duplicateOf: {type: ["string", "null"], description: "Duplicate of issue ID. Null to remove"},
38
+ milestone: {type: "string", description: "Milestone name or ID"},
39
+ delegate: {type: ["string", "null"], description: "Agent name or ID. Null to remove"},
40
+ links: {type: "array", items: {type: "object", properties: {url: {type: "string"}, title: {type: "string"}}, required: ["url", "title"]}, description: "Link attachments [{url, title}]"}
41
+ },
42
+ required: ["id"],
43
+ additionalProperties: false
44
+ )
45
+ # standard:enable Layout/LineLength
46
+
47
+ MUTATION = <<~GRAPHQL
48
+ mutation($id: String!, $input: IssueUpdateInput!) {
49
+ issueUpdate(id: $id, input: $input) {
50
+ success
51
+ issue {
52
+ id identifier title url
53
+ state { name }
54
+ assignee { id name }
55
+ team { id name }
56
+ labels { nodes { name } }
57
+ project { id name }
58
+ }
59
+ }
60
+ }
61
+ GRAPHQL
62
+
63
+ RELATIONS_QUERY = <<~GRAPHQL
64
+ query($id: String!) {
65
+ issue(id: $id) {
66
+ relations { nodes { id type relatedIssue { id } } }
67
+ }
68
+ }
69
+ GRAPHQL
70
+
71
+ RELATION_DELETE_MUTATION = <<~GRAPHQL
72
+ mutation($id: String!) {
73
+ issueRelationDelete(id: $id) { success }
74
+ }
75
+ GRAPHQL
76
+
77
+ ISSUE_TEAM_QUERY = <<~GRAPHQL
78
+ query($id: String!) {
79
+ issue(id: $id) { team { id } }
80
+ }
81
+ GRAPHQL
82
+
83
+ RELATION_MUTATION = <<~GRAPHQL
84
+ mutation($input: IssueRelationCreateInput!) {
85
+ issueRelationCreate(input: $input) { success }
86
+ }
87
+ GRAPHQL
88
+
89
+ LINK_MUTATION = <<~GRAPHQL
90
+ mutation($url: String!, $issueId: String!, $title: String) {
91
+ attachmentLinkURL(url: $url, issueId: $issueId, title: $title) { success }
92
+ }
93
+ GRAPHQL
94
+
95
+ RELATION_TYPE_MAP = {
96
+ blockedBy: "isBlockedBy",
97
+ blocks: "blocks",
98
+ relatedTo: "related",
99
+ duplicateOf: "duplicate"
100
+ }.freeze
101
+
102
+ # standard:disable Naming/VariableName, Metrics/MethodLength
103
+ class << self
104
+ # @param id [String] issue ID
105
+ # @param server_context [Hash, nil] must contain +:client+ key
106
+ # @return [MCP::Tool::Response] TOON-encoded issue or error
107
+ def call(id:, server_context: nil, **kwargs)
108
+ client = server_context&.dig(:client) or raise Error, "client missing from server_context"
109
+ raise Error, "Cannot specify both assignee and delegate" if kwargs.key?(:assignee) && kwargs.key?(:delegate)
110
+
111
+ input = {}
112
+ team_id = resolve_team_id(client, id, kwargs)
113
+ build_input(input, client, team_id, kwargs)
114
+
115
+ data = client.query(MUTATION, variables: {id:, input:})
116
+ result = data["issueUpdate"]
117
+ raise Error, "Issue update failed" unless result["success"]
118
+
119
+ issue = result["issue"]
120
+ replace_relations(client, id, kwargs)
121
+ create_links(client, id, kwargs[:links])
122
+
123
+ text = Toon.encode(issue)
124
+ MCP::Tool::Response.new([{type: "text", text:}])
125
+ rescue Error => e
126
+ MCP::Tool::Response.new([{type: "text", text: e.message}], error: true)
127
+ end
128
+
129
+ private
130
+
131
+ def resolve_team_id(client, issue_id, kwargs)
132
+ return Resolvers.resolve_team(client, kwargs[:team]) if kwargs.key?(:team)
133
+ return unless needs_team_id?(kwargs)
134
+
135
+ data = client.query(ISSUE_TEAM_QUERY, variables: {id: issue_id})
136
+ data.dig("issue", "team", "id") or raise Error, "Could not determine issue team"
137
+ end
138
+
139
+ def needs_team_id?(kwargs)
140
+ kwargs.key?(:state) || kwargs.key?(:cycle)
141
+ end
142
+
143
+ def build_input(input, client, team_id, kwargs)
144
+ add_direct_fields(input, kwargs)
145
+ add_nullable_fields(input, client, kwargs)
146
+ add_resolved_fields(input, client, team_id, kwargs)
147
+ end
148
+
149
+ def add_direct_fields(input, kwargs)
150
+ {title: :title, description: :description, priority: :priority,
151
+ estimate: :estimate, dueDate: :dueDate}.each do |key, field|
152
+ input[field] = kwargs[key] if kwargs.key?(key)
153
+ end
154
+ end
155
+
156
+ def add_nullable_fields(input, client, kwargs)
157
+ if kwargs.key?(:assignee)
158
+ input[:assigneeId] = kwargs[:assignee] ? Resolvers.resolve_user(client, kwargs[:assignee]) : nil
159
+ end
160
+ if kwargs.key?(:delegate)
161
+ input[:assigneeId] = kwargs[:delegate] ? Resolvers.resolve_user(client, kwargs[:delegate]) : nil
162
+ end
163
+ input[:parentId] = kwargs[:parentId] if kwargs.key?(:parentId)
164
+ end
165
+
166
+ def add_resolved_fields(input, client, team_id, kwargs)
167
+ input[:teamId] = team_id if kwargs.key?(:team) && team_id
168
+ input[:stateId] = Resolvers.resolve_state(client, team_id, kwargs[:state]) if kwargs.key?(:state) && team_id
169
+ input[:labelIds] = Resolvers.resolve_labels(client, kwargs[:labels]) if kwargs.key?(:labels)
170
+ project_id = nil
171
+ if kwargs.key?(:project) && kwargs[:project]
172
+ project_id = Resolvers.resolve_project(client, kwargs[:project])
173
+ input[:projectId] = project_id
174
+ end
175
+ if kwargs.key?(:milestone)
176
+ raise Error, "milestone requires project" unless project_id
177
+ input[:projectMilestoneId] = Resolvers.resolve_milestone(client, project_id, kwargs[:milestone])
178
+ end
179
+ input[:cycleId] = Resolvers.resolve_cycle(client, team_id, kwargs[:cycle]) if kwargs.key?(:cycle) && team_id
180
+ end
181
+
182
+ def replace_relations(client, issue_id, kwargs)
183
+ RELATION_TYPE_MAP.each do |param, type|
184
+ next unless kwargs.key?(param)
185
+ values = (param == :duplicateOf) ? [kwargs[param]].compact : Array(kwargs[param])
186
+ delete_existing_relations(client, issue_id, type)
187
+ values.each do |related_id|
188
+ input = {issueId: issue_id, relatedIssueId: related_id, type:}
189
+ data = client.query(RELATION_MUTATION, variables: {input:})
190
+ next if data.dig("issueRelationCreate", "success")
191
+ raise Error, "Failed to create #{type} relation with #{related_id}"
192
+ end
193
+ end
194
+ end
195
+
196
+ def delete_existing_relations(client, issue_id, type)
197
+ data = client.query(RELATIONS_QUERY, variables: {id: issue_id})
198
+ relations = data.dig("issue", "relations", "nodes") || []
199
+ relations.each do |rel|
200
+ next unless rel["type"] == type
201
+ del = client.query(RELATION_DELETE_MUTATION, variables: {id: rel["id"]})
202
+ next if del.dig("issueRelationDelete", "success")
203
+ raise Error, "Failed to delete #{type} relation #{rel["id"]}"
204
+ end
205
+ end
206
+
207
+ def create_links(client, issue_id, links)
208
+ return unless links
209
+
210
+ links.each do |link|
211
+ vars = {url: link["url"], issueId: issue_id, title: link["title"]}
212
+ data = client.query(LINK_MUTATION, variables: vars)
213
+ next if data.dig("attachmentLinkURL", "success")
214
+ raise Error, "Failed to attach link: #{link["url"]}"
215
+ end
216
+ end
217
+ end
218
+ # standard:enable Naming/VariableName, Metrics/MethodLength
219
+ end
220
+ end
221
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module LinearToonMcp
4
- VERSION = "0.0.1"
4
+ VERSION = "0.3.0"
5
5
  end
@@ -2,17 +2,30 @@
2
2
 
3
3
  require "mcp"
4
4
  require_relative "linear_toon_mcp/version"
5
- require_relative "linear_toon_mcp/tools/echo"
5
+ require_relative "linear_toon_mcp/client"
6
+ require_relative "linear_toon_mcp/resolvers"
7
+ require_relative "linear_toon_mcp/tools/get_issue"
8
+ require_relative "linear_toon_mcp/tools/list_issues"
9
+ require_relative "linear_toon_mcp/tools/create_comment"
10
+ require_relative "linear_toon_mcp/tools/create_issue"
11
+ require_relative "linear_toon_mcp/tools/update_issue"
6
12
 
13
+ # Token-efficient MCP server for Linear. Wraps Linear's GraphQL API
14
+ # and returns TOON-formatted responses for ~40-60% token savings.
7
15
  module LinearToonMcp
16
+ # Raised on Linear API HTTP errors, GraphQL errors, or missing data.
8
17
  class Error < StandardError; end
9
18
 
10
- def self.server
19
+ # Build a configured MCP::Server with all registered tools.
20
+ # @param client [Client] Linear API client (defaults to new instance from ENV)
21
+ # @return [MCP::Server]
22
+ def self.server(client: Client.new)
11
23
  MCP::Server.new(
12
24
  name: "linear-toon-mcp",
13
25
  version: VERSION,
14
26
  description: "Manage Linear issues, projects, and teams",
15
- tools: [Tools::Echo]
27
+ tools: [Tools::GetIssue, Tools::ListIssues, Tools::CreateComment, Tools::CreateIssue, Tools::UpdateIssue],
28
+ server_context: {client:}
16
29
  )
17
30
  end
18
31
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: linear-toon-mcp
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.1
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yevhenii Hurin
@@ -23,6 +23,20 @@ dependencies:
23
23
  - - "~>"
24
24
  - !ruby/object:Gem::Version
25
25
  version: '0.6'
26
+ - !ruby/object:Gem::Dependency
27
+ name: openssl
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: 3.3.1
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: 3.3.1
26
40
  - !ruby/object:Gem::Dependency
27
41
  name: toon-ruby
28
42
  requirement: !ruby/object:Gem::Requirement
@@ -50,7 +64,13 @@ files:
50
64
  - README.md
51
65
  - bin/linear-toon-mcp
52
66
  - lib/linear_toon_mcp.rb
53
- - lib/linear_toon_mcp/tools/echo.rb
67
+ - lib/linear_toon_mcp/client.rb
68
+ - lib/linear_toon_mcp/resolvers.rb
69
+ - lib/linear_toon_mcp/tools/create_comment.rb
70
+ - lib/linear_toon_mcp/tools/create_issue.rb
71
+ - lib/linear_toon_mcp/tools/get_issue.rb
72
+ - lib/linear_toon_mcp/tools/list_issues.rb
73
+ - lib/linear_toon_mcp/tools/update_issue.rb
54
74
  - lib/linear_toon_mcp/version.rb
55
75
  homepage: https://github.com/hoblin/linear-toon-mcp
56
76
  licenses:
@@ -1,29 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module LinearToonMcp
4
- module Tools
5
- class Echo < MCP::Tool
6
- description "Accepts text input and returns it as-is"
7
-
8
- annotations(
9
- read_only_hint: true,
10
- destructive_hint: false,
11
- idempotent_hint: true
12
- )
13
-
14
- input_schema(
15
- properties: {
16
- text: {type: "string", description: "Text to echo back"}
17
- },
18
- required: ["text"],
19
- additionalProperties: false
20
- )
21
-
22
- class << self
23
- def call(text:, server_context: nil)
24
- MCP::Tool::Response.new([{type: "text", text:}])
25
- end
26
- end
27
- end
28
- end
29
- end