linear-toon-mcp 0.6.1 → 1.0.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.
Files changed (54) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +59 -14
  3. data/lib/linear_toon_mcp/resolvers/base.rb +217 -0
  4. data/lib/linear_toon_mcp/resolvers/cycle.rb +11 -0
  5. data/lib/linear_toon_mcp/resolvers/initiative.rb +10 -0
  6. data/lib/linear_toon_mcp/resolvers/issue_label.rb +19 -0
  7. data/lib/linear_toon_mcp/resolvers/project.rb +10 -0
  8. data/lib/linear_toon_mcp/resolvers/project_milestone.rb +11 -0
  9. data/lib/linear_toon_mcp/resolvers/project_status.rb +12 -0
  10. data/lib/linear_toon_mcp/resolvers/team.rb +10 -0
  11. data/lib/linear_toon_mcp/resolvers/user.rb +24 -0
  12. data/lib/linear_toon_mcp/resolvers/workflow_state.rb +12 -0
  13. data/lib/linear_toon_mcp/resolvers.rb +19 -174
  14. data/lib/linear_toon_mcp/tools/add_project_to_initiative.rb +48 -0
  15. data/lib/linear_toon_mcp/tools/archive_project.rb +41 -0
  16. data/lib/linear_toon_mcp/tools/base.rb +76 -0
  17. data/lib/linear_toon_mcp/tools/create.rb +83 -0
  18. data/lib/linear_toon_mcp/tools/create_issue_label.rb +48 -0
  19. data/lib/linear_toon_mcp/tools/delete.rb +79 -0
  20. data/lib/linear_toon_mcp/tools/delete_comment.rb +34 -0
  21. data/lib/linear_toon_mcp/tools/delete_initiative.rb +77 -0
  22. data/lib/linear_toon_mcp/tools/delete_status_update.rb +57 -0
  23. data/lib/linear_toon_mcp/tools/get.rb +69 -0
  24. data/lib/linear_toon_mcp/tools/get_initiative.rb +71 -0
  25. data/lib/linear_toon_mcp/tools/get_issue.rb +1 -18
  26. data/lib/linear_toon_mcp/tools/get_issue_status.rb +47 -0
  27. data/lib/linear_toon_mcp/tools/get_project.rb +20 -34
  28. data/lib/linear_toon_mcp/tools/get_status_update.rb +81 -0
  29. data/lib/linear_toon_mcp/tools/get_team.rb +45 -0
  30. data/lib/linear_toon_mcp/tools/get_user.rb +44 -0
  31. data/lib/linear_toon_mcp/tools/list.rb +58 -0
  32. data/lib/linear_toon_mcp/tools/list_comments.rb +48 -34
  33. data/lib/linear_toon_mcp/tools/list_cycles.rb +5 -17
  34. data/lib/linear_toon_mcp/tools/list_initiatives.rb +124 -0
  35. data/lib/linear_toon_mcp/tools/list_issue_labels.rb +6 -23
  36. data/lib/linear_toon_mcp/tools/list_issue_statuses.rb +7 -17
  37. data/lib/linear_toon_mcp/tools/list_issues.rb +65 -93
  38. data/lib/linear_toon_mcp/tools/list_projects.rb +6 -23
  39. data/lib/linear_toon_mcp/tools/list_status_updates.rb +105 -0
  40. data/lib/linear_toon_mcp/tools/list_teams.rb +1 -17
  41. data/lib/linear_toon_mcp/tools/list_users.rb +7 -24
  42. data/lib/linear_toon_mcp/tools/remove_project_from_initiative.rb +62 -0
  43. data/lib/linear_toon_mcp/tools/save_comment.rb +121 -0
  44. data/lib/linear_toon_mcp/tools/save_initiative.rb +114 -0
  45. data/lib/linear_toon_mcp/tools/save_issue.rb +292 -0
  46. data/lib/linear_toon_mcp/tools/save_project.rb +173 -0
  47. data/lib/linear_toon_mcp/tools/save_status_update.rb +158 -0
  48. data/lib/linear_toon_mcp/tools.rb +45 -0
  49. data/lib/linear_toon_mcp/version.rb +1 -1
  50. data/lib/linear_toon_mcp.rb +29 -18
  51. metadata +36 -4
  52. data/lib/linear_toon_mcp/tools/create_comment.rb +0 -69
  53. data/lib/linear_toon_mcp/tools/create_issue.rb +0 -173
  54. data/lib/linear_toon_mcp/tools/update_issue.rb +0 -239
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0daf1704fb96cea3eec8dbd60cb0c51a0facdd3e2ede5c3575b4f312fdde23c6
4
- data.tar.gz: 8f45de9bd2c1a409b506cd87cfa47479b4aeaf731b0f928d84882eb21aeed9fd
3
+ metadata.gz: 4548c28ca6deb74cc3ea72d889aad5f931ed4d63265f9d742f8ad54b1af651b9
4
+ data.tar.gz: 8089aab524d5febf2207243b5de33524dc2a264fdc8624a009d1ed3fbf4ce9a0
5
5
  SHA512:
6
- metadata.gz: 9a1609b91fc4d8a4dd5fa5f46b77fe3bd38cf48a022dd1de9f00432ac29ab55a7c55ada94b0acc9342dc50134890f272db9531a6e6fb7e7e7736f766ef4c58c1
7
- data.tar.gz: 7b9adcc7084b17bb7167ff469679559a033f217bcb523591bd5a74cee79f51798aa9c5f22695937738f4175bab45691c59da8e16b341f182e53f4f68d74561cf
6
+ metadata.gz: 64044b9580eef34aab49067d721ee0435b38658a6bd99ba5594260457b1424beb4f9f3236de429986a6f099c2c60debfa9a72635538ac095f91f5440b343adf0
7
+ data.tar.gz: 2f679acce91253b8632c913c3be49bb0329aa8a2eb84538205a24ef248fcf7e0448b9ff0b8263087cbc39a993c23cf383f3868868ca94d7691b3c695051ebfbc
data/README.md CHANGED
@@ -42,21 +42,66 @@ claude mcp add linear-toon -e LINEAR_API_KEY=lin_api_xxxxx -- linear-toon-mcp
42
42
 
43
43
  ## Tools
44
44
 
45
+ Most parameters accept either a UUID or a human identifier (issue identifier like `LIN-123`, team key like `VIB`, project/initiative/label name, user email or `"me"`); the server resolves it. `save_*` tools dispatch by `id` presence — provide an `id` to update, omit it to create. All responses are TOON-encoded.
46
+
47
+ ### Issues
48
+
49
+ | Tool | Description |
50
+ |------|-------------|
51
+ | `get_issue` | Retrieve an issue by ID or identifier (e.g., `LIN-123`). Returns the issue plus its parent and direct children. |
52
+ | `list_issues` | List issues with filters (team, assignee, state, label, priority, project, cycle) and cursor pagination. |
53
+ | `save_issue` | Create or update an issue. Accepts names for team, assignee, state, labels, project, cycle, and milestone. Relation params (`blocks`, `relatedTo`, `duplicateOf`) and `parentId` accept UUIDs or identifiers. On create, relations are appended; on update, they replace existing. Null clears nullable fields on update. |
54
+
55
+ ### Comments
56
+
57
+ | Tool | Description |
58
+ |------|-------------|
59
+ | `list_comments` | List comments on an issue, project, initiative, or project status update (exactly one parent). Cursor-paginated. |
60
+ | `save_comment` | Create or update a comment. On create, exactly one of `issue` / `project` / `initiative` / `projectUpdate` identifies the parent. Supports threaded replies via `parentId`. |
61
+ | `delete_comment` | Delete a comment by ID. |
62
+
63
+ ### Projects
64
+
65
+ | Tool | Description |
66
+ |------|-------------|
67
+ | `list_projects` | List projects, optionally scoped to a team. Returns id, name, and status. |
68
+ | `get_project` | Retrieve a project by name, ID, or slug. Optional includes for members, milestones, and resources. |
69
+ | `save_project` | Create or update a project. On create, `name` and `teams` (one or more) are required. Resolves names to IDs for teams, lead, members, labels, status, and initiative (initiative links on creation only — use `add_project_to_initiative` afterwards). |
70
+ | `archive_project` | Archive a project (recoverable soft delete). Linear has no hard-delete for projects. |
71
+
72
+ ### Initiatives
73
+
74
+ | Tool | Description |
75
+ |------|-------------|
76
+ | `list_initiatives` | List initiatives with filters (status, owner, parent initiative, date ranges) and cursor pagination. Optional `includeProjects` adds linked projects. |
77
+ | `get_initiative` | Retrieve an initiative by name or ID. Returns linked projects. Optional `includeSubInitiatives`. |
78
+ | `save_initiative` | Create or update an initiative. Resolves `owner` and `parentInitiative` names to IDs. Exposes both `description` (short summary, ~255 chars) and `content` (long Markdown). |
79
+ | `delete_initiative` | Delete an initiative. `archive: true` soft-deletes via `initiativeArchive`. Hard delete is refused while projects are still linked — unlink first or archive. |
80
+ | `add_project_to_initiative` | Link a project to an initiative. |
81
+ | `remove_project_from_initiative` | Unlink a project from an initiative; finds and deletes the underlying join record. |
82
+
83
+ ### Status updates
84
+
85
+ | Tool | Description |
86
+ |------|-------------|
87
+ | `list_status_updates` | List status updates posted to a project or initiative (exactly one parent). Cursor-paginated. |
88
+ | `get_status_update` | Retrieve a status update by ID — transparently fetches whichever parent type owns it. |
89
+ | `save_status_update` | Create or update a status update on a project or initiative. `health` enum: `onTrack` / `atRisk` / `offTrack`. Body is Markdown. |
90
+ | `delete_status_update` | Archive a status update by ID. Linear has no hard-delete for status updates; this maps to `*UpdateArchive`. |
91
+
92
+ ### Workspace
93
+
45
94
  | Tool | Description |
46
95
  |------|-------------|
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, attachments, and the parent and direct child issues (each with identifier, title, state, and url). |
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
- | `list_issue_statuses` | List available workflow states for a team. Returns status id, type (backlog/unstarted/started/completed/canceled), and name. Accepts team name or UUID. |
50
- | `list_teams` | List all teams in the workspace. Returns team id, name, and key. |
51
- | `list_users` | List users in the workspace, optionally scoped to a team. Returns user id, name, and email. |
52
- | `list_issue_labels` | List issue labels, optionally scoped to a team. Returns label id and name. |
53
- | `list_projects` | List projects, optionally scoped to a team. Returns project id, name, and state. |
54
- | `list_cycles` | List cycles for a team. Returns cycle id, name, number, startsAt, and endsAt. Requires team name or UUID. |
55
- | `get_project` | Retrieve a specific project by name, ID, or slug. Returns project details including state, priority, dates, progress, and lead. Optional includes for members, milestones, and resources. |
56
- | `create_issue` | Create a new Linear issue. Accepts human-friendly names for team, assignee, state, labels, project, cycle, and milestone (resolved to IDs automatically; label names resolve against the target team or workspace-wide labels). Relation params (`blocks`, `relatedTo`, `duplicateOf`) and `parentId` accept either issue UUIDs or human identifiers (e.g., `LIN-123`). Supports issue relations and link attachments. |
57
- | `update_issue` | Update an existing Linear issue by ID. Supports partial updates, null to remove fields, and relation replacement. Relation params (`blocks`, `relatedTo`, `duplicateOf`) and `parentId` accept either issue UUIDs or human identifiers (e.g., `LIN-123`). Label names resolve against the issue's team or workspace-wide labels. |
58
- | `create_comment` | Create a comment on a Linear issue. Supports Markdown content and threaded replies via parentId. |
59
- | `list_comments` | List comments for a specific Linear issue in chronological order. Returns each comment's id, body, author, and timestamps. |
96
+ | `list_teams` | List all teams. Returns id, name, and key. |
97
+ | `get_team` | Retrieve a team by id, key (e.g., `VIB`), or name. |
98
+ | `list_users` | List users, optionally scoped to a team. |
99
+ | `get_user` | Retrieve a user by id, name, email, or `"me"`. |
100
+ | `list_cycles` | List cycles for a team. Returns id, name, number, startsAt, endsAt. |
101
+ | `list_issue_statuses` | List workflow states for a team. Returns id, type (`backlog`, `unstarted`, `started`, `completed`, `canceled`, `triage`, `duplicate`), and name. |
102
+ | `get_issue_status` | Retrieve a workflow state by name or UUID, team-scoped. |
103
+ | `list_issue_labels` | List issue labels, optionally scoped to a team. |
104
+ | `create_issue_label` | Create an issue label. Omit `team` for a workspace-wide label. |
60
105
 
61
106
  ## Development
62
107
 
@@ -70,7 +115,7 @@ bundle exec standardrb # lint
70
115
 
71
116
  ## Versioning
72
117
 
73
- Version `1.0.0` means feature parity with the official Linear MCP server. Until then, each new tool (or set of tools) bumps the minor version. The single source of truth is `lib/linear_toon_mcp/version.rb`.
118
+ [Semantic versioning](https://semver.org/). Breaking tool removals or rename go in a major bump, new tools or new optional parameters go in a minor, fixes and internal refactors go in a patch. The single source of truth is `lib/linear_toon_mcp/version.rb`.
74
119
 
75
120
  ## Releasing
76
121
 
@@ -0,0 +1,217 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LinearToonMcp
4
+ module Resolvers
5
+ # Base class for entity resolvers. Subclasses declare their lookup
6
+ # attributes and any required parent scope. UUIDs always pass through
7
+ # unchanged regardless of the declared attributes.
8
+ #
9
+ # Defaults derive from the class name:
10
+ #
11
+ # WorkflowState.connection_name # => "workflowStates"
12
+ # WorkflowState.filter_type_name # => "WorkflowStateFilter"
13
+ # WorkflowState.entity_label # => "State"
14
+ #
15
+ # Override any default via {.connection}, {.filter_type}, or {.label}.
16
+ class Base
17
+ # Lookup attribute catalog: value predicate paired with GraphQL filter builder.
18
+ ATTRIBUTES = {
19
+ name: {
20
+ matches: ->(_v) { true },
21
+ filter: ->(v) { {name: {eqIgnoreCase: v}} }
22
+ },
23
+ email: {
24
+ matches: ->(v) { v.include?("@") },
25
+ filter: ->(v) { {email: {eq: v}} }
26
+ },
27
+ number: {
28
+ matches: ->(v) { v.match?(NUMERIC_RE) },
29
+ filter: ->(v) { {number: {eq: v.to_i}} }
30
+ },
31
+ slug: {
32
+ matches: ->(_v) { true },
33
+ filter: ->(v) { {slugId: {eqIgnoreCase: v}} }
34
+ },
35
+ key: {
36
+ matches: ->(v) { v.match?(/\A[A-Z]+\z/) },
37
+ filter: ->(v) { {key: {eq: v}} }
38
+ },
39
+ type: {
40
+ matches: ->(v) { v.match?(/\A[a-z]+\z/) },
41
+ filter: ->(v) { {type: {eq: v}} }
42
+ }
43
+ }.freeze
44
+
45
+ class << self
46
+ # Declares lookup attributes for this resolver, in priority order.
47
+ #
48
+ # class Team < Base
49
+ # lookup_by :key, :name
50
+ # end
51
+ #
52
+ # @param attrs [Array<Symbol>] attribute names from {ATTRIBUTES}
53
+ def lookup_by(*attrs)
54
+ @attributes = attrs.freeze
55
+ end
56
+
57
+ # Declares a parent-scoping kwarg expected by {.call}. The kwarg name
58
+ # implies the GraphQL filter key — +:team_id+ produces
59
+ # +{team: {id: {eq: value}}}+.
60
+ #
61
+ # class Cycle < Base
62
+ # scoped_by :team_id
63
+ # lookup_by :name
64
+ # end
65
+ #
66
+ # @param key [Symbol] kwarg name passed to {.call}
67
+ # @param optional [Boolean] omit the scope filter when scope arg is nil
68
+ # @param workspace_fallback [Boolean] when set, the scope filter becomes
69
+ # an +or:+ matching either the scoped parent or workspace-level
70
+ # records (parent +null+)
71
+ def scoped_by(key, optional: false, workspace_fallback: false)
72
+ @scope_config = {key: key, optional: optional, workspace_fallback: workspace_fallback}.freeze
73
+ end
74
+
75
+ # Overrides the derived GraphQL connection name.
76
+ def connection(name)
77
+ @connection = name.to_s
78
+ end
79
+
80
+ # Overrides the derived GraphQL filter type name.
81
+ def filter_type(name)
82
+ @filter_type = name.to_s
83
+ end
84
+
85
+ # Overrides the derived not-found label.
86
+ def label(name)
87
+ @label = name.to_s
88
+ end
89
+
90
+ # Returns the attributes declared via {.lookup_by}.
91
+ def attributes
92
+ @attributes || []
93
+ end
94
+
95
+ attr_reader :scope_config
96
+
97
+ # Returns the GraphQL connection name.
98
+ #
99
+ # WorkflowState.connection_name # => "workflowStates"
100
+ def connection_name
101
+ @connection ||= "#{entity_name[0].downcase}#{entity_name[1..]}s"
102
+ end
103
+
104
+ # Returns the GraphQL filter input type name.
105
+ #
106
+ # WorkflowState.filter_type_name # => "WorkflowStateFilter"
107
+ def filter_type_name
108
+ @filter_type ||= "#{entity_name}Filter"
109
+ end
110
+
111
+ # Returns the not-found label — the trailing CamelCase word of
112
+ # {.entity_name}.
113
+ #
114
+ # WorkflowState.entity_label # => "State"
115
+ def entity_label
116
+ @label ||= entity_name.scan(/[A-Z][a-z]+/).last || entity_name
117
+ end
118
+
119
+ # Returns the entity name.
120
+ #
121
+ # WorkflowState.entity_name # => "WorkflowState"
122
+ def entity_name
123
+ @entity_name ||= name.split("::").last
124
+ end
125
+
126
+ # Returns the memoized GraphQL query.
127
+ def query
128
+ @query ||= <<~GRAPHQL
129
+ query($filter: #{filter_type_name}) {
130
+ #{connection_name}(filter: $filter, first: 1) { nodes { id } }
131
+ }
132
+ GRAPHQL
133
+ end
134
+
135
+ # Resolves +value+ to a UUID using {LinearToonMcp.client}.
136
+ #
137
+ # Team.call(value: "Engineering")
138
+ # WorkflowState.call(value: "Done", team_id: tid)
139
+ #
140
+ # @param value [String]
141
+ # @param scope [Hash] parent-scope kwargs (e.g. +team_id:+)
142
+ # @return [String] resolved UUID
143
+ # @raise [Error] when no attribute resolves the value
144
+ def call(value:, **scope)
145
+ new(**scope).resolve(value)
146
+ end
147
+
148
+ # Resolves each value via {.call}, forwarding scope.
149
+ #
150
+ # IssueLabel.call_many(values: ["bug", "p1"], team_id: tid)
151
+ #
152
+ # @return [Array<String>]
153
+ def call_many(values:, **scope)
154
+ values.map { |v| call(value: v, **scope) }
155
+ end
156
+ end
157
+
158
+ def initialize(**scope)
159
+ @scope = scope
160
+ end
161
+
162
+ # Resolves +value+ to a UUID. UUIDs pass through unchanged; otherwise
163
+ # each {.lookup_by} attribute is tried in declared order and the first
164
+ # GraphQL lookup that returns a node wins.
165
+ #
166
+ # @raise [Error] when nothing resolves +value+
167
+ def resolve(value)
168
+ return value if value.match?(UUID_RE)
169
+
170
+ self.class.attributes.each do |attr|
171
+ definition = ATTRIBUTES.fetch(attr) { raise Error, "Unknown attribute: #{attr.inspect}" }
172
+ next unless definition[:matches].call(value)
173
+
174
+ id = lookup(definition[:filter].call(value).merge(scope_filter))
175
+ return id if id
176
+ end
177
+
178
+ raise Error, not_found_message(value)
179
+ end
180
+
181
+ private
182
+
183
+ attr_reader :scope
184
+
185
+ def client
186
+ LinearToonMcp.client
187
+ end
188
+
189
+ def scope_filter
190
+ cfg = self.class.scope_config
191
+ return {} unless cfg
192
+
193
+ scope_id = scope[cfg[:key]]
194
+ if scope_id.nil?
195
+ return {} if cfg[:optional]
196
+ raise Error, "Missing required scope: #{cfg[:key]}"
197
+ end
198
+
199
+ parent_field = cfg[:key].to_s.sub(/_id\z/, "").to_sym
200
+ if cfg[:workspace_fallback]
201
+ {or: [{parent_field => {null: true}}, {parent_field => {id: {eq: scope_id}}}]}
202
+ else
203
+ {parent_field => {id: {eq: scope_id}}}
204
+ end
205
+ end
206
+
207
+ def lookup(filter)
208
+ data = client.query(self.class.query, variables: {filter:})
209
+ data.dig(self.class.connection_name, "nodes", 0, "id")
210
+ end
211
+
212
+ def not_found_message(value)
213
+ "#{self.class.entity_label} not found: #{value}"
214
+ end
215
+ end
216
+ end
217
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LinearToonMcp
4
+ module Resolvers
5
+ # Resolves a Linear cycle by number or name. Team-scoped.
6
+ class Cycle < Base
7
+ scoped_by :team_id
8
+ lookup_by :number, :name
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LinearToonMcp
4
+ module Resolvers
5
+ # Resolves a Linear initiative by name.
6
+ class Initiative < Base
7
+ lookup_by :name
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LinearToonMcp
4
+ module Resolvers
5
+ # Resolves a Linear issue label by name. Optionally scoped to a team;
6
+ # when scoped, matches either the team's labels or workspace-wide labels
7
+ # (team +null+).
8
+ class IssueLabel < Base
9
+ scoped_by :team_id, optional: true, workspace_fallback: true
10
+ lookup_by :name
11
+
12
+ private
13
+
14
+ def not_found_message(value)
15
+ scope[:team_id] ? "Label not found on target team or workspace: #{value}" : super
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LinearToonMcp
4
+ module Resolvers
5
+ # Resolves a Linear project by name or slug.
6
+ class Project < Base
7
+ lookup_by :name, :slug
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LinearToonMcp
4
+ module Resolvers
5
+ # Resolves a Linear project milestone by name. Project-scoped.
6
+ class ProjectMilestone < Base
7
+ scoped_by :project_id
8
+ lookup_by :name
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LinearToonMcp
4
+ module Resolvers
5
+ # Resolves a Linear project status by name (workspace-scoped).
6
+ class ProjectStatus < Base
7
+ connection :projectStatuses
8
+ filter_type :ProjectStatusFilter
9
+ lookup_by :name
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LinearToonMcp
4
+ module Resolvers
5
+ # Resolves a Linear team by key or name.
6
+ class Team < Base
7
+ lookup_by :key, :name
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LinearToonMcp
4
+ module Resolvers
5
+ # Resolves a Linear user by email, name, or the literal +"me"+.
6
+ class User < Base
7
+ VIEWER_QUERY = "query { viewer { id } }"
8
+
9
+ lookup_by :email, :name
10
+
11
+ def resolve(value)
12
+ return resolve_viewer if value == "me"
13
+ super
14
+ end
15
+
16
+ private
17
+
18
+ def resolve_viewer
19
+ data = client.query(VIEWER_QUERY)
20
+ data.dig("viewer", "id") || raise(Error, "Could not resolve current user")
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LinearToonMcp
4
+ module Resolvers
5
+ # Resolves a Linear workflow state (issue status) by type or name.
6
+ # Team-scoped.
7
+ class WorkflowState < Base
8
+ scoped_by :team_id
9
+ lookup_by :type, :name
10
+ end
11
+ end
12
+ end
@@ -1,182 +1,27 @@
1
1
  # frozen_string_literal: true
2
2
 
3
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.
4
+ # Resolvers translate human-friendly identifiers UUIDs, names, emails,
5
+ # slugs, numbers, the literal "me" into Linear API UUIDs. They read
6
+ # {LinearToonMcp.client} for API calls.
7
+ #
8
+ # @example
9
+ # Resolvers::Team.call(value: "Engineering")
10
+ # Resolvers::WorkflowState.call(value: "In Progress", team_id: tid)
11
+ # Resolvers::IssueLabel.call_many(values: ["bug", "p1"], team_id: tid)
6
12
  module Resolvers
7
13
  UUID_RE = /\A\h{8}-\h{4}-\h{4}-\h{4}-\h{12}\z/
8
14
  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
- # Resolve a single label name or UUID to its UUID.
96
- # When +team_id+ is supplied, restricts the lookup to labels scoped to that
97
- # team or to workspace-wide labels (where the label's team relation is null),
98
- # so a name like "Bug" matches the right team-scoped label and not a same-named
99
- # label on another team.
100
- # @param client [Client]
101
- # @param value [String] label UUID or name
102
- # @param team_id [String, nil] optional team UUID to scope the lookup
103
- # @return [String] label UUID
104
- # @raise [Error] when label not found
105
- def resolve_label(client, value, team_id: nil)
106
- return value if value.match?(UUID_RE)
107
-
108
- filter = {name: {eqIgnoreCase: value}}
109
- if team_id
110
- filter[:or] = [
111
- {team: {null: true}},
112
- {team: {id: {eq: team_id}}}
113
- ]
114
- end
115
-
116
- data = client.query(LABEL_QUERY, variables: {filter:})
117
- id = data.dig("issueLabels", "nodes", 0, "id")
118
- return id if id
119
-
120
- raise Error, label_not_found_message(value, team_id)
121
- end
122
-
123
- # @param client [Client]
124
- # @param values [Array<String>] label UUIDs or names
125
- # @param team_id [String, nil] optional team UUID to scope the lookup
126
- # @return [Array<String>] label UUIDs
127
- def resolve_labels(client, values, team_id: nil)
128
- values.map { |v| resolve_label(client, v, team_id:) }
129
- end
130
-
131
- def label_not_found_message(value, team_id)
132
- return "Label not found: #{value}" unless team_id
133
- "Label not found on target team or workspace: #{value}"
134
- end
135
-
136
- # @param client [Client]
137
- # @param value [String] project UUID, name, or slug
138
- # @return [String] project UUID
139
- # @raise [Error] when project not found
140
- def resolve_project(client, value)
141
- return value if value.match?(UUID_RE)
142
-
143
- # Try name first, then slug
144
- data = client.query(PROJECT_QUERY, variables: {filter: {name: {eqIgnoreCase: value}}})
145
- id = data.dig("projects", "nodes", 0, "id")
146
- return id if id
147
-
148
- data = client.query(PROJECT_QUERY, variables: {filter: {slugId: {eqIgnoreCase: value}}})
149
- data.dig("projects", "nodes", 0, "id") or raise Error, "Project not found: #{value}"
150
- end
151
-
152
- # @param client [Client]
153
- # @param team_id [String] team UUID (for scoping)
154
- # @param value [String] cycle UUID, number, or name
155
- # @return [String] cycle UUID
156
- # @raise [Error] when cycle not found
157
- def resolve_cycle(client, team_id, value)
158
- return value if value.match?(UUID_RE)
159
-
160
- filter = if value.match?(NUMERIC_RE)
161
- {number: {eq: value.to_i}, team: {id: {eq: team_id}}}
162
- else
163
- {name: {eqIgnoreCase: value}, team: {id: {eq: team_id}}}
164
- end
165
-
166
- data = client.query(CYCLE_QUERY, variables: {filter:})
167
- data.dig("cycles", "nodes", 0, "id") or raise Error, "Cycle not found: #{value}"
168
- end
169
-
170
- # @param client [Client]
171
- # @param project_id [String] project UUID (for scoping)
172
- # @param value [String] milestone UUID or name
173
- # @return [String] milestone UUID
174
- # @raise [Error] when milestone not found
175
- def resolve_milestone(client, project_id, value)
176
- return value if value.match?(UUID_RE)
177
- filter = {name: {eqIgnoreCase: value}, project: {id: {eq: project_id}}}
178
- data = client.query(MILESTONE_QUERY, variables: {filter:})
179
- data.dig("projectMilestones", "nodes", 0, "id") or raise Error, "Milestone not found: #{value}"
180
- end
181
15
  end
182
16
  end
17
+
18
+ require_relative "resolvers/base"
19
+ require_relative "resolvers/team"
20
+ require_relative "resolvers/user"
21
+ require_relative "resolvers/workflow_state"
22
+ require_relative "resolvers/issue_label"
23
+ require_relative "resolvers/project"
24
+ require_relative "resolvers/cycle"
25
+ require_relative "resolvers/project_milestone"
26
+ require_relative "resolvers/initiative"
27
+ require_relative "resolvers/project_status"