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 +4 -4
- data/README.md +12 -13
- data/lib/linear_toon_mcp/client.rb +62 -0
- data/lib/linear_toon_mcp/resolvers.rb +152 -0
- data/lib/linear_toon_mcp/tools/create_comment.rb +69 -0
- data/lib/linear_toon_mcp/tools/create_issue.rb +157 -0
- data/lib/linear_toon_mcp/tools/get_issue.rb +69 -0
- data/lib/linear_toon_mcp/tools/list_issues.rb +171 -0
- data/lib/linear_toon_mcp/tools/update_issue.rb +221 -0
- data/lib/linear_toon_mcp/version.rb +1 -1
- data/lib/linear_toon_mcp.rb +16 -3
- metadata +22 -2
- data/lib/linear_toon_mcp/tools/echo.rb +0 -29
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6b25cc1890ddb1f779b8adcd4636e6d4415035c671e91f5da91f5df78d5b03e6
|
|
4
|
+
data.tar.gz: ee755802e2a8ab10e7008c0a21779fe1a4058a29ca117d2c56cb9d8761620854
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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
|
data/lib/linear_toon_mcp.rb
CHANGED
|
@@ -2,17 +2,30 @@
|
|
|
2
2
|
|
|
3
3
|
require "mcp"
|
|
4
4
|
require_relative "linear_toon_mcp/version"
|
|
5
|
-
require_relative "linear_toon_mcp/
|
|
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
|
-
|
|
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::
|
|
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
|
|
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/
|
|
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
|