linear-toon-mcp 0.0.1 → 0.2.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: 50a8ce17d061de79481c874e5cd0bd06cb2ab7656101798fb9e312ba4e890a4f
4
+ data.tar.gz: 986cd4e9927a53c969f9a2359013a626b5dfe0b9de2e201b5a5e52cb5faa4635
5
5
  SHA512:
6
- metadata.gz: b3d8fd5d7acaf9abda70b2c612a425eca4af435331a2088669f89564f28fb4166e74eecbbc54607a027b0e97dbdc0ee7abdfe026a22839a98030654b4fe0ac16
7
- data.tar.gz: f218f5f454747918fc0a522cc91622f2c899edc52af3f097da95fcbabf0aeac5a9c4e8e49c2afd0cc8686a3a2d1c943fb2a055939392b14e2e7575f8b920137b
6
+ metadata.gz: 4755fd71c9c8cf68259264351fd3e9cf9cadf6f1af6371377f28c944ae0af3bf67d300173c382d0eba92a3dd132738a5d7e2e6067dba9721f69ae624ae29d15d
7
+ data.tar.gz: 0bd32d72d4fe123c043107de21cb50dbe1387b4ae4b76e48ccb4105a7555ffd068991c756acca755666b201a06484ce83c6bd7a03a7134662f06a3a4bddfe17d
data/README.md CHANGED
@@ -36,21 +36,17 @@ 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
+
54
50
  ## Development
55
51
 
56
52
  ```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,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,173 @@
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
+ # @private
67
+ UUID_RE = /\A\h{8}-\h{4}-\h{4}-\h{4}-\h{12}\z/
68
+ # @private
69
+ NUMERIC_RE = /\A\d+\z/
70
+
71
+ # standard:disable Naming/VariableName
72
+ class << self
73
+ # @param assignee [String, nil] user ID, name, email, or "me"
74
+ # @param createdAt [String, nil] ISO-8601 date or duration
75
+ # @param cursor [String, nil] pagination cursor
76
+ # @param cycle [String, nil] cycle name, number, or ID
77
+ # @param delegate [String, nil] agent name or ID
78
+ # @param includeArchived [Boolean, nil] include archived issues
79
+ # @param label [String, nil] label name or ID
80
+ # @param limit [Integer, nil] max results (default 50, max 250)
81
+ # @param orderBy [String, nil] "createdAt" or "updatedAt"
82
+ # @param parentId [String, nil] parent issue ID
83
+ # @param priority [Integer, nil] 0-4
84
+ # @param project [String, nil] project name or ID
85
+ # @param query [String, nil] search title or description
86
+ # @param state [String, nil] state name or ID
87
+ # @param team [String, nil] team name or ID
88
+ # @param updatedAt [String, nil] ISO-8601 date or duration
89
+ # @param server_context [Hash, nil] must contain +:client+ key with a {Client}
90
+ # @return [MCP::Tool::Response] TOON-encoded issue list or error
91
+ def call(assignee: nil, createdAt: nil, cursor: nil, cycle: nil,
92
+ delegate: nil, includeArchived: nil, label: nil, limit: nil, orderBy: nil,
93
+ parentId: nil, priority: nil, project: nil, query: nil, state: nil,
94
+ team: nil, updatedAt: nil, server_context: nil)
95
+ client = server_context&.dig(:client) or raise Error, "client missing from server_context"
96
+
97
+ filter = build_filter(
98
+ assignee:, team:, project:, state:, label:,
99
+ priority:, parentId:, cycle:, delegate:,
100
+ query:, createdAt:, updatedAt:
101
+ )
102
+
103
+ variables = {
104
+ first: (limit || 50).clamp(1, 250),
105
+ orderBy: orderBy || "updatedAt",
106
+ includeArchived: includeArchived != false
107
+ }
108
+ variables[:filter] = filter unless filter.empty?
109
+ variables[:after] = cursor if cursor
110
+
111
+ data = client.query(QUERY, variables:)
112
+ issues = data["issues"] or raise Error, "Unexpected response: missing issues field"
113
+ text = Toon.encode(issues)
114
+ MCP::Tool::Response.new([{type: "text", text:}])
115
+ rescue Error => e
116
+ MCP::Tool::Response.new([{type: "text", text: e.message}], error: true)
117
+ end
118
+
119
+ private
120
+
121
+ def build_filter(assignee:, team:, project:, state:, label:,
122
+ priority:, parentId:, cycle:, delegate:,
123
+ query:, createdAt:, updatedAt:)
124
+ filter = {}
125
+ filter[:assignee] = assignee_filter(assignee) if assignee
126
+ filter[:team] = name_or_id(team) if team
127
+ filter[:project] = name_or_id(project) if project
128
+ filter[:state] = name_or_id(state) if state
129
+ filter[:labels] = {some: name_or_id(label)} if label
130
+ filter[:priority] = {eq: priority} if priority
131
+ filter[:parent] = {id: {eq: parentId}} if parentId
132
+ filter[:cycle] = cycle_filter(cycle) if cycle
133
+ filter[:delegate] = name_or_id(delegate) if delegate
134
+ if query
135
+ filter[:or] = [
136
+ {title: {containsIgnoreCase: query}},
137
+ {description: {containsIgnoreCase: query}}
138
+ ]
139
+ end
140
+ filter[:createdAt] = {gte: resolve_date(createdAt)} if createdAt
141
+ filter[:updatedAt] = {gte: resolve_date(updatedAt)} if updatedAt
142
+ filter
143
+ end
144
+ # standard:enable Naming/VariableName
145
+
146
+ def assignee_filter(value)
147
+ return {isMe: {eq: true}} if value == "me"
148
+ return {id: {eq: value}} if value.match?(UUID_RE)
149
+ return {email: {eq: value}} if value.include?("@")
150
+ {name: {eqIgnoreCase: value}}
151
+ end
152
+
153
+ def name_or_id(value)
154
+ value.match?(UUID_RE) ? {id: {eq: value}} : {name: {eqIgnoreCase: value}}
155
+ end
156
+
157
+ def cycle_filter(value)
158
+ return {id: {eq: value}} if value.match?(UUID_RE)
159
+ return {number: {eq: value.to_i}} if value.match?(NUMERIC_RE)
160
+ {name: {eqIgnoreCase: value}}
161
+ end
162
+
163
+ def resolve_date(value)
164
+ return value unless value.start_with?("-P")
165
+ match = value.match(/\A-P(?:(\d+)Y)?(?:(\d+)M)?(?:(\d+)W)?(?:(\d+)D)?\z/)
166
+ raise Error, "Invalid duration: #{value}" unless match
167
+ days = match.captures.zip([365, 30, 7, 1]).sum { |c, m| (c&.to_i || 0) * m }
168
+ (Time.now.utc - (days * 86_400)).iso8601
169
+ end
170
+ end
171
+ end
172
+ end
173
+ 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.2.0"
5
5
  end
@@ -2,17 +2,26 @@
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/tools/get_issue"
7
+ require_relative "linear_toon_mcp/tools/list_issues"
6
8
 
9
+ # Token-efficient MCP server for Linear. Wraps Linear's GraphQL API
10
+ # and returns TOON-formatted responses for ~40-60% token savings.
7
11
  module LinearToonMcp
12
+ # Raised on Linear API HTTP errors, GraphQL errors, or missing data.
8
13
  class Error < StandardError; end
9
14
 
10
- def self.server
15
+ # Build a configured MCP::Server with all registered tools.
16
+ # @param client [Client] Linear API client (defaults to new instance from ENV)
17
+ # @return [MCP::Server]
18
+ def self.server(client: Client.new)
11
19
  MCP::Server.new(
12
20
  name: "linear-toon-mcp",
13
21
  version: VERSION,
14
22
  description: "Manage Linear issues, projects, and teams",
15
- tools: [Tools::Echo]
23
+ tools: [Tools::GetIssue, Tools::ListIssues],
24
+ server_context: {client:}
16
25
  )
17
26
  end
18
27
  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.2.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,9 @@ 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/tools/get_issue.rb
69
+ - lib/linear_toon_mcp/tools/list_issues.rb
54
70
  - lib/linear_toon_mcp/version.rb
55
71
  homepage: https://github.com/hoblin/linear-toon-mcp
56
72
  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