@kb1slnlabs/dsh-linear 0.0.0-stage → 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.
package/index.js ADDED
@@ -0,0 +1,1460 @@
1
+ // @kb1slnlabs/dsh-linear — Linear issue-tracking tools for the DeepSeek Harness.
2
+ //
3
+ // A Cordis function/namespace plugin: exports { name, inject, apply } (NO default
4
+ // export — a stray default collapses the module via the Loader's unwrapExports).
5
+ // `apply` registers model-facing tools through ctx.tools.register.
6
+ //
7
+ // One tool surface:
8
+ // GraphQL tools (linear_*): 26 hand-written tools that talk to the Linear
9
+ // GraphQL API (https://api.linear.app/graphql) with a personal API token
10
+ // (env LINEAR_API_KEY or config apiKey). There is no MCP server and no MCP
11
+ // client here — no connection to mcp.linear.app is ever opened, and no MCP
12
+ // configuration exists. The 26 tools below are the entire surface.
13
+ //
14
+ // Peer deps (@deepseek-ai/dsh-tools, @deepseek-ai/cordis) resolve
15
+ // from the dsh install's node_modules — this file lives outside any pnpm tree,
16
+ // so a plain import would not find them; the resolvers below pin resolution to
17
+ // the dsh install root by absolute file:// URL.
18
+ //
19
+ // GraphQL tools:
20
+ // linear_issue_list — list issues (filter by team, state, search, limit, cursor pagination)
21
+ // linear_issue_get — one issue by id or identifier (e.g. "APT-123")
22
+ // linear_issue_create — create an issue in a team
23
+ // linear_issue_update — update an issue (state, description, assignee, labels, priority)
24
+ // linear_issue_archive — archive an issue (id or identifier)
25
+ // linear_issue_unarchive — unarchive a previously archived issue
26
+ // linear_comment_add — add a comment to an issue
27
+ // linear_comment_update — update a comment body (comment UUID)
28
+ // linear_comment_delete — delete a comment (comment UUID)
29
+ // linear_comment_list — list comments on an issue (cursor pagination)
30
+ // linear_relation_add — link a related issue to an issue
31
+ // linear_team_list — list teams
32
+ // linear_team_get — one team by id (UUID) or key
33
+ // linear_state_list — list workflow states for a team
34
+ // linear_label_list — list labels for a team
35
+ // linear_label_get — one label by id (UUID)
36
+ // linear_label_create — create a label for a team
37
+ // linear_project_list — list projects
38
+ // linear_project_get — one project by id (UUID)
39
+ // linear_project_create — create a project
40
+ // linear_project_update — update a project (name, description, content, dates, teams)
41
+ // linear_cycle_list — list cycles for a team
42
+ // linear_cycle_get — one cycle by id (UUID)
43
+ // linear_user_list — list users in the workspace
44
+ // linear_user_get — one user by id (UUID) or email
45
+ // linear_graphql — direct GraphQL escape hatch (caller's own query or
46
+ // mutation document; coverage is whatever the Linear
47
+ // API exposes to the key, not named-tool parity)
48
+
49
+ import { createRequire } from 'node:module'
50
+ import { fileURLToPath, pathToFileURL } from 'node:url'
51
+ import { homedir } from 'node:os'
52
+ import { dirname, join } from 'node:path'
53
+
54
+ // The plugin source lives outside any pnpm tree, so a bare import('@deepseek-ai/dsh-tools')
55
+ // cannot resolve from here. Every dependency is therefore requested as a STANDARD bare
56
+ // specifier ('@deepseek-ai/dsh-tools', '@deepseek-ai/schemastery') and resolved through the
57
+ // packages' own "exports" maps — never through a hardcoded entry filename. Node's own resolver
58
+ // walks up from this file, so
59
+ // when the plugin is installed inside a tree that carries these packages (or one of their
60
+ // peer locations), plain resolution finds them with no anchoring at all. Only when that
61
+ // fails do we fall back to resolving from the dsh install root, derived portably from
62
+ // process.execPath (the npm-global prefix layout on any platform), and only as a last
63
+ // resort is the historical Windows %APPDATA% location probed.
64
+ //
65
+ // We use import() (NOT require()): a synchronous require() of dsh-tools — an ESM module —
66
+ // at module-eval time races the harness's own concurrent import() of the same module and
67
+ // trips Node's ERR_INTERNAL_ASSERTION ("module status 0 ... not yet fully loaded").
68
+ // import() of an absolute file:// URL awaits the same module job and is race-free.
69
+ //
70
+ // Resolution order for every dependency below (identical shape):
71
+ // 1. the DSH_LINEAR_* override env var (explicit entry, any resolvable path/URL)
72
+ // 2. plain Node resolution of the bare specifier from this file
73
+ // 3. the same bare specifier resolved from the dsh install root
74
+
75
+ /** Resolve a bare specifier to a file:// URL, or undefined if nothing provides it. */
76
+ function tryResolveFrom(fromFile, specifier) {
77
+ try {
78
+ return pathToFileURL(createRequire(fromFile).resolve(specifier)).href
79
+ } catch {
80
+ return undefined
81
+ }
82
+ }
83
+
84
+ /** This file's path — the anchor for plain (package.json-driven) resolution. */
85
+ const selfPath = fileURLToPath(import.meta.url)
86
+
87
+ /**
88
+ * Anchor for peer resolution inside a dsh install: the installed @deepseek-ai/dsh package
89
+ * itself, located by walking up from process.execPath to the nearest ancestor that carries
90
+ * .../node_modules/@deepseek-ai/dsh/package.json. execPath-based (not a hardcoded home
91
+ * directory) so it works for any npm/pnpm global prefix on Windows, macOS or Linux.
92
+ */
93
+ function dshInstallAnchor() {
94
+ let dir = dirname((process.execPath || '').replace(/[\\/]+$/, '') || '.')
95
+ for (let depth = 0; depth < 12; depth += 1) {
96
+ const candidate = join(dir, 'node_modules', '@deepseek-ai', 'dsh', 'package.json')
97
+ if (tryResolveFrom(join(dir, 'noop.js'), '@deepseek-ai/dsh/package.json')) return candidate
98
+ const parent = dirname(dir)
99
+ if (parent === dir) break
100
+ dir = parent
101
+ }
102
+ return undefined
103
+ }
104
+
105
+ /**
106
+ * Portable fallback anchors for the dsh install's node_modules, in preference order:
107
+ * 1. npm/pnpm global prefix derived from process.execPath (<prefix>/node_modules)
108
+ * 2. %APPDATA%/npm/node_modules (the historical Windows npm-global location)
109
+ * 3. /usr/local/lib/node_modules and /usr/lib/node_modules (POSIX npm-global defaults)
110
+ */
111
+ function fallbackNodeModulesRoots() {
112
+ const roots = []
113
+ const execPath = (process.execPath || '').replace(/\\/g, '/')
114
+ const marker = '/node_modules/'
115
+ const idx = execPath.lastIndexOf(marker)
116
+ if (idx !== -1) roots.push(execPath.slice(0, idx + '/node_modules'.length))
117
+ if (process.platform === 'win32') {
118
+ roots.push(join(process.env.APPDATA || join(homedir(), 'AppData', 'Roaming'), 'npm', 'node_modules'))
119
+ } else {
120
+ roots.push('/usr/local/lib/node_modules', '/usr/lib/node_modules')
121
+ }
122
+ return roots
123
+ }
124
+
125
+ /**
126
+ * Resolve one bare specifier: plain resolution from this file first, then the dsh install
127
+ * anchor, then each fallback node_modules root. `override` (a DSH_LINEAR_* env var) wins
128
+ * over all of them when set. The final entry is the resolved file:// URL, so import() stays
129
+ * race-free; if nothing resolves, the plain specifier is returned so import() raises Node's
130
+ * own module-not-found error.
131
+ *
132
+ * Peer dependencies are declared as BOTH `dependencies` and `peerDependencies`, because
133
+ * either location may be what Node finds — a copy installed next to this plugin, or the host
134
+ * dsh install's copy.
135
+ */
136
+ function resolveEntry(override, specifier) {
137
+ if (override) return override
138
+ return tryResolveFrom(selfPath, specifier) ?? tryResolveFrom(dshInstallAnchor(), specifier) ?? tryResolveFromFromRoots(specifier) ?? specifier
139
+ }
140
+
141
+ /** Last resort: the same bare specifier resolved from each npm-global fallback root. */
142
+ function tryResolveFromFromRoots(specifier) {
143
+ for (const root of fallbackNodeModulesRoots()) {
144
+ const entry = tryResolveFrom(join(root, 'noop.js'), specifier)
145
+ if (entry !== undefined) return entry
146
+ }
147
+ return undefined
148
+ }
149
+
150
+ function resolveDshToolsEntry() {
151
+ return resolveEntry(process.env.DSH_LINEAR_TOOLS, '@deepseek-ai/dsh-tools')
152
+ }
153
+
154
+ // Same resolution chain, but for the schemastery schema lib (used to declare the plugin Config).
155
+ function resolveDshSchemasteryEntry() {
156
+ return resolveEntry(process.env.DSH_LINEAR_SCHEMA, '@deepseek-ai/schemastery')
157
+ }
158
+
159
+ const { defineTool } = await import(resolveDshToolsEntry())
160
+ // schemastery's exports map serves the ESM entry to an import in a tree that provides it;
161
+ // resolved from the dsh install root the same specifier may land on its CJS entry, which
162
+ // Node wraps with both `default` and the named bindings. Take whichever shape is real.
163
+ const schemastery = await import(resolveDshSchemasteryEntry())
164
+ const z = typeof schemastery.default?.object === 'function' ? schemastery.default : schemastery
165
+
166
+ const name = 'linear'
167
+ const inject = ['tools']
168
+
169
+ const API_URL = 'https://api.linear.app/graphql'
170
+
171
+ // ---------------------------------------------------------------------------
172
+ // GraphQL client
173
+ // ---------------------------------------------------------------------------
174
+
175
+ function resolveKey(config) {
176
+ const key = config?.apiKey || process.env.LINEAR_API_KEY
177
+ if (!key) throw new Error('Linear API key not configured: set the LINEAR_API_KEY environment variable (or pass `apiKey` in plugin config).')
178
+ return key
179
+ }
180
+
181
+ async function gql(key, query, variables) {
182
+ const res = await fetch(API_URL, {
183
+ method: 'POST',
184
+ headers: {
185
+ 'Content-Type': 'application/json',
186
+ // Linear API keys are sent as the raw Authorization value — NOT "Bearer <key>".
187
+ Authorization: key,
188
+ },
189
+ body: JSON.stringify({ query, variables }),
190
+ })
191
+ const body = await res.json().catch(() => null)
192
+ if (!res.ok || body?.errors?.length) {
193
+ const msg = body?.errors?.map((e) => e.message).join('; ') || `HTTP ${res.status}`
194
+ throw new Error(`Linear API error: ${msg}`)
195
+ }
196
+ return body.data
197
+ }
198
+
199
+ // ---------------------------------------------------------------------------
200
+ // Field fragments
201
+ // ---------------------------------------------------------------------------
202
+
203
+ const ISSUE_FIELDS = `
204
+ id identifier title description state { id name type }
205
+ assignee { id name }
206
+ priority
207
+ createdAt updatedAt
208
+ labels { nodes { id name } }
209
+ project { id name }
210
+ url
211
+ `
212
+
213
+ // Connection pagination block for list connections (issues, comments). Every
214
+ // direct list tool that supports `after` requests this alongside `nodes`.
215
+ const PAGE_INFO_FIELDS = `
216
+ pageInfo { hasNextPage endCursor }
217
+ `
218
+
219
+ const COMMENT_FIELDS = `
220
+ id body createdAt
221
+ `
222
+
223
+ const TEAM_FIELDS = `
224
+ id name key description
225
+ `
226
+
227
+ const STATE_FIELDS = `
228
+ id name type position
229
+ `
230
+
231
+ const LABEL_FIELDS = `
232
+ id name color description
233
+ `
234
+
235
+ const PROJECT_FIELDS = `
236
+ id name description state
237
+ teams { id name key }
238
+ `
239
+
240
+ const CYCLE_FIELDS = `
241
+ id number name startsAt endsAt completedAt
242
+ progress
243
+ team { id name key }
244
+ `
245
+
246
+ const USER_FIELDS = `
247
+ id name displayName email active
248
+ `
249
+
250
+ // ---------------------------------------------------------------------------
251
+ // Helpers
252
+ // ---------------------------------------------------------------------------
253
+
254
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
255
+
256
+ // Cursor pagination (direct GraphQL list tools).
257
+ //
258
+ // `after` is an optional opaque cursor: it is ALWAYS sent as a GraphQL variable
259
+ // (`$after: String`), never interpolated into the query text, so a cursor can
260
+ // never alter the document. When no cursor is supplied the declaration and the
261
+ // `after:` argument are both omitted, which keeps the wire request byte-identical
262
+ // to the pre-pagination form (backwards compatibility). Every direct list tool
263
+ // uses this shape: linear_issue_list, linear_comment_list, linear_team_list,
264
+ // linear_state_list, linear_label_list, linear_project_list, linear_cycle_list
265
+ // and linear_user_list.
266
+ // The result shape mirrors GraphQL's PageInfo — null when the server did not
267
+ // report one, so an older/blank response never invents pagination state.
268
+ function normalizePageInfo(pageInfo) {
269
+ if (!pageInfo || typeof pageInfo !== 'object') return null
270
+ return {
271
+ hasNextPage: pageInfo.hasNextPage === true,
272
+ endCursor: typeof pageInfo.endCursor === 'string' ? pageInfo.endCursor : null,
273
+ }
274
+ }
275
+
276
+ // ---------------------------------------------------------------------------
277
+ // Entity resolution (resolve-then-mutate)
278
+ // ---------------------------------------------------------------------------
279
+ //
280
+ // Linear's GraphQL inputs are FLAT UUID fields — there is no nested
281
+ // `team`/`state` object on IssueCreateInput/IssueUpdateInput, and the singular
282
+ // `Query.team` field accepts ONLY a UUID `id` (no `key`/`name`/`filter`).
283
+ // So every human-facing identifier (team key, state name) must first be
284
+ // resolved to a UUID through the PLURAL `teams` / `workflowStates` query
285
+ // fields, which DO accept filters:
286
+ //
287
+ // teams(filter: { key: { eq: "APT" } }) { nodes { id key name } }
288
+ // workflowStates(filter: { team: { id: { eq: <teamId> } },
289
+ // name: { eq: "Backlog" } }) { nodes { id name } }
290
+ //
291
+ // The resolvers below throw a clear error when the identifier does not resolve
292
+ // to exactly one entity, so a bad key never reaches a mutation as a malformed
293
+ // nested object.
294
+
295
+ // Resolve a team key or name to its UUID via the PLURAL `teams` field (which
296
+ // accepts a TeamFilter). Pass a UUID through unchanged.
297
+ async function resolveTeamId(key, ref) {
298
+ if (UUID_RE.test(ref)) return ref
299
+ const isEmail = ref.includes('@')
300
+ const data = await gql(key, `
301
+ query($v: String!) {
302
+ teams(first: 10, filter: { ${isEmail ? 'name' : 'key'}: { eq: $v } }) {
303
+ nodes { id key name }
304
+ }
305
+ }
306
+ `, { v: ref })
307
+ const nodes = data.teams?.nodes ?? []
308
+ if (nodes.length !== 1) {
309
+ throw new Error(`Team '${ref}' not found (resolved ${nodes.length} teams via the teams filter)`)
310
+ }
311
+ return nodes[0].id
312
+ }
313
+
314
+ // Resolve a workflow-state name to its UUID within a team (UUID required).
315
+ // Uses the top-level `workflowStates` query field, filtered by team id + name.
316
+ async function resolveStateId(key, teamId, stateName) {
317
+ const data = await gql(key, `
318
+ query($teamId: String!, $name: String!) {
319
+ workflowStates(filter: { team: { id: { eq: $teamId } }, name: { eq: $name } }) {
320
+ nodes { id name }
321
+ }
322
+ }
323
+ `, { teamId, name: stateName })
324
+ const nodes = data.workflowStates?.nodes ?? []
325
+ if (nodes.length !== 1) {
326
+ throw new Error(`State '${stateName}' not found for team ${teamId} (resolved ${nodes.length} states)`)
327
+ }
328
+ return nodes[0].id
329
+ }
330
+
331
+ // Resolve a project name or a UUID to a Linear project UUID. The singular
332
+ // `project` field accepts ONLY a UUID id, so a name goes through the PLURAL
333
+ // `projects` field (which takes a ProjectFilter). Unlike teams, a non-UUID that
334
+ // simply matches nothing returns null here rather than throwing, so the caller
335
+ // renders the same `Project not found: <x>` shape the read tools use.
336
+ // More than one node is NOT silently shortened to the first one: an update aimed
337
+ // at an ambiguous name could modify the wrong project, so it throws and
338
+ // resolveLinearProjectId turns that into a clean { error } before any mutation.
339
+ async function findProjectId(key, ref) {
340
+ if (UUID_RE.test(ref)) return ref
341
+ const data = await gql(key, `
342
+ query($name: String!) {
343
+ projects(filter: { name: { eq: $name } }) {
344
+ nodes { id }
345
+ }
346
+ }
347
+ `, { name: ref })
348
+ const nodes = data.projects?.nodes ?? []
349
+ if (nodes.length > 1) {
350
+ throw new Error(`Project name '${ref}' is ambiguous (resolved ${nodes.length} projects via the projects filter); use the project id (UUID) instead`)
351
+ }
352
+ return nodes[0]?.id ?? null
353
+ }
354
+
355
+ // findProjectId with its two failure shapes rendered for a tool: `null` means
356
+ // genuinely not found, and an ambiguity throw becomes its own clean error string.
357
+ async function resolveLinearProjectId(key, ref) {
358
+ try {
359
+ const projectId = await findProjectId(key, ref)
360
+ return { projectId }
361
+ } catch (err) {
362
+ if (/is ambiguous/.test(err?.message ?? '')) {
363
+ return { error: `Project name '${ref}' matched multiple projects — refine the name or use the project id (UUID)` }
364
+ }
365
+ throw err
366
+ }
367
+ }
368
+
369
+ // Resolve a human identifier ("APT-804") or a UUID to a Linear issue UUID.
370
+ // IssueFilter has no `identifier` field, so we split the identifier into
371
+ // team key + number and look the issue up by those, then return its id.
372
+ // `includeArchived` opts into IssuesQuery.includeArchived: an ARCHIVED issue is
373
+ // invisible to the plain issues query, so a tool that must reach one (only
374
+ // linear_issue_unarchive) passes it. Every other caller omits the option, which
375
+ // keeps the document byte-for-byte what it was before — no extra argument and
376
+ // no extra variable are sent when includeArchived is falsy.
377
+ async function findIssueId(key, id, { includeArchived = false } = {}) {
378
+ if (UUID_RE.test(id)) return id
379
+ const m = id.match(/^(.+?)-(\d+)$/)
380
+ if (!m) return null
381
+ const [, teamKey, num] = m
382
+ const data = await gql(key, `
383
+ query($team: String!, $num: Float!) {
384
+ issues(filter: { team: { key: { eq: $team } }, number: { eq: $num } }${includeArchived ? ', includeArchived: true' : ''}) {
385
+ nodes { id }
386
+ }
387
+ }
388
+ `, { team: teamKey, num: Number(num) })
389
+ return data.issues.nodes[0]?.id ?? null
390
+ }
391
+
392
+ // Shared body for linear_issue_archive / linear_issue_unarchive. Both mutations
393
+ // take a bare `id` argument (there is no IssueArchiveInput type) and both return
394
+ // IssueArchivePayload { success entity lastSyncId } — the archived issue comes
395
+ // back under `entity`, NOT `issue` (verified by introspection). The identifier is
396
+ // resolved to a UUID first, so an unknown "APT-999" fails before any mutation.
397
+ // Unarchiving resolves the identifier with includeArchived: true, because an
398
+ // archived issue is exactly what the plain issues query hides — without it the
399
+ // tool could never unarchive anything it identified by name. Archiving keeps the
400
+ // ordinary lookup (an archived issue needs no second archive).
401
+ async function archiveIssue(key, id, unarchive) {
402
+ const issueId = await findIssueId(key, id, { includeArchived: unarchive })
403
+ if (!issueId) return JSON.stringify({ error: `Issue not found: ${id}` })
404
+ const mutation = unarchive ? 'issueUnarchive' : 'issueArchive'
405
+ const data = await gql(key, `
406
+ mutation($id: String!) {
407
+ ${mutation}(id: $id) {
408
+ success entity { ${ISSUE_FIELDS} }
409
+ }
410
+ }
411
+ `, { id: issueId })
412
+ const payload = data[mutation]
413
+ if (!payload?.success) return JSON.stringify({ error: `${mutation} failed` })
414
+ const i = payload.entity
415
+ if (!i) return JSON.stringify({ ok: true, archived: !unarchive })
416
+ return JSON.stringify({
417
+ ok: true,
418
+ archived: !unarchive,
419
+ id: i.id,
420
+ identifier: i.identifier,
421
+ title: i.title,
422
+ state: i.state?.name,
423
+ url: i.url,
424
+ }, null, 2)
425
+ }
426
+
427
+ // ---------------------------------------------------------------------------
428
+ // GraphQL tools
429
+ // ---------------------------------------------------------------------------
430
+
431
+ function registerLinearTools(ctx, config) {
432
+ const key = () => resolveKey(config)
433
+
434
+ // Wrap only Linear tools so any failure (a resolution error like
435
+ // "Team 'X' not found", a Linear API error, or a network error) surfaces as
436
+ // a clean JSON { error } payload instead of a thrown stack trace. The
437
+ // resolution helpers throw deliberately; this guard turns that into the
438
+ // clear, model-readable error the bug report requires.
439
+ const originalRegister = ctx.tools.register.bind(ctx.tools)
440
+ const register = (def) =>
441
+ originalRegister({
442
+ ...def,
443
+ execute: async (args, opts) => {
444
+ try {
445
+ return await def.execute(args, opts)
446
+ } catch (err) {
447
+ return JSON.stringify({ error: err?.message || String(err) })
448
+ }
449
+ },
450
+ })
451
+
452
+ // -- Issues ---------------------------------------------------------------
453
+
454
+ register(
455
+ defineTool({
456
+ name: 'linear_issue_list',
457
+ description:
458
+ 'List Linear issues. Optionally filter by team key (e.g. "APT"), state name, or free-text search. Returns up to `limit` most recent issues (default 20, max 50). Cursor pagination: pass the previous page\'s pageInfo.endCursor as `after` to get the next page; the response includes pageInfo { hasNextPage, endCursor }.',
459
+ parameters: {
460
+ team: { type: 'string', description: 'Team key to filter by, e.g. "APT"' },
461
+ state: { type: 'string', description: 'State name to filter by, e.g. "Done", "In Progress"' },
462
+ search: { type: 'string', description: 'Free-text search across title/description' },
463
+ limit: { type: 'number', description: 'Max issues to return (default 20, max 50)' },
464
+ after: { type: 'string', description: 'Cursor from a previous page (pageInfo.endCursor) to fetch the next page' },
465
+ },
466
+ output: {
467
+ schema: { type: 'string' },
468
+ render: (_a, v) => [{ type: 'text', text: v }],
469
+ },
470
+ async execute(args) {
471
+ const limit = Math.min(Math.max(1, Math.floor(args.limit ?? 20)), 50)
472
+ // Optional cursor. Kept as a variable (`$after`), never interpolated.
473
+ const after = typeof args.after === 'string' && args.after.trim() ? args.after : undefined
474
+ const filterParts = []
475
+ const vars = {}
476
+ if (args.team) { filterParts.push(`team: { key: { eq: $team } }`); vars.team = args.team }
477
+ if (args.state) { filterParts.push(`state: { name: { eq: $state } }`); vars.state = args.state }
478
+ if (args.search) { filterParts.push(`OR: { title: { containsInsensitive: $search }, description: { containsInsensitive: $search } }`); vars.search = args.search }
479
+ const filter = filterParts.length ? `filter: { ${filterParts.join(', ')} }` : ''
480
+ if (after !== undefined) vars.after = after
481
+ const usedVars = Object.keys(vars)
482
+ const varDecl = usedVars.length ? `query(${usedVars.map((v) => `$${v}: String`).join(', ')})` : 'query'
483
+ // One shared argument list: the cursor is declared only when it is also
484
+ // passed, so a call with no `after` sends exactly the pre-pagination document.
485
+ const argList = [filter, after !== undefined ? 'after: $after' : null].filter(Boolean).join(', ')
486
+ const data = await gql(key(), `
487
+ ${varDecl} {
488
+ issues(first: ${limit}${argList ? `, ${argList}` : ''}) {
489
+ nodes { ${ISSUE_FIELDS} }
490
+ ${PAGE_INFO_FIELDS}
491
+ }
492
+ }
493
+ `, vars)
494
+ const issues = data.issues.nodes.map((i) => ({
495
+ id: i.id,
496
+ identifier: i.identifier,
497
+ title: i.title,
498
+ state: i.state?.name,
499
+ assignee: i.assignee?.name,
500
+ priority: i.priority,
501
+ project: i.project?.name,
502
+ labels: i.labels.nodes.map((l) => l.name),
503
+ url: i.url,
504
+ }))
505
+ return JSON.stringify({ count: issues.length, issues, pageInfo: normalizePageInfo(data.issues.pageInfo) }, null, 2)
506
+ },
507
+ }),
508
+ )
509
+
510
+ register(
511
+ defineTool({
512
+ name: 'linear_issue_get',
513
+ description:
514
+ 'Get a single Linear issue by its id (UUID) or identifier (e.g. "APT-123"). Returns full details including description, state, assignee, labels, project, and relations.',
515
+ parameters: {
516
+ id: { type: 'string', required: true, description: 'Issue id (UUID) or identifier like "APT-123"' },
517
+ },
518
+ output: {
519
+ schema: { type: 'string' },
520
+ render: (_a, v) => [{ type: 'text', text: v }],
521
+ },
522
+ async execute(args) {
523
+ const issueId = await findIssueId(key(), args.id)
524
+ if (!issueId) return JSON.stringify({ error: `Issue not found: ${args.id}` })
525
+ const data = await gql(key(), `
526
+ query($id: String!) {
527
+ issue: issue(id: $id) {
528
+ ${ISSUE_FIELDS}
529
+ relations { nodes { type relatedIssue { ${ISSUE_FIELDS} } } }
530
+ }
531
+ }
532
+ `, { id: issueId })
533
+ const i = data.issue
534
+ if (!i) return JSON.stringify({ error: `Issue not found: ${args.id}` })
535
+ return JSON.stringify({
536
+ id: i.id,
537
+ identifier: i.identifier,
538
+ title: i.title,
539
+ description: i.description,
540
+ state: i.state?.name,
541
+ assignee: i.assignee?.name,
542
+ priority: i.priority,
543
+ project: i.project?.name,
544
+ labels: i.labels.nodes.map((l) => l.name),
545
+ relations: (i.relations?.nodes ?? i.relations ?? []).map((r) => r.relatedIssue && { id: r.relatedIssue.id, identifier: r.relatedIssue.identifier, title: r.relatedIssue.title, type: r.type }),
546
+ url: i.url,
547
+ createdAt: i.createdAt,
548
+ updatedAt: i.updatedAt,
549
+ }, null, 2)
550
+ },
551
+ }),
552
+ )
553
+
554
+ register(
555
+ defineTool({
556
+ name: 'linear_issue_create',
557
+ description:
558
+ 'Create a new Linear issue in a team. `team` is the team key (e.g. "APT"). Optional: title (required), description, state name, assignee id, label ids, priority (0-4).',
559
+ parameters: {
560
+ team: { type: 'string', required: true, description: 'Team key, e.g. "APT"' },
561
+ title: { type: 'string', required: true, description: 'Issue title' },
562
+ description: { type: 'string', description: 'Issue description (markdown)' },
563
+ state: { type: 'string', description: 'Initial state name, e.g. "Todo", "In Progress"' },
564
+ assignee: { type: 'string', description: 'Assignee user id (UUID)' },
565
+ labels: { type: 'array', items: { type: 'string' }, description: 'Label ids (UUIDs) to attach' },
566
+ priority: { type: 'number', description: 'Priority 0 (none) to 4 (urgent)' },
567
+ },
568
+ output: {
569
+ schema: { type: 'string' },
570
+ render: (_a, v) => [{ type: 'text', text: v }],
571
+ },
572
+ async execute(args) {
573
+ // Resolve human identifiers to UUIDs first (IssueCreateInput is flat:
574
+ // teamId is required, stateId/assigneeId/labelIds are UUIDs).
575
+ const teamId = await resolveTeamId(key(), args.team)
576
+ const input = { teamId, title: args.title }
577
+ if (args.description) input.description = args.description
578
+ if (args.state) input.stateId = await resolveStateId(key(), teamId, args.state)
579
+ if (args.assignee) input.assigneeId = args.assignee
580
+ if (args.labels?.length) input.labelIds = args.labels
581
+ if (args.priority !== undefined) input.priority = args.priority
582
+ const data = await gql(key(), `
583
+ mutation($input: IssueCreateInput!) {
584
+ issueCreate(input: $input) {
585
+ success issue { ${ISSUE_FIELDS} }
586
+ }
587
+ }
588
+ `, { input })
589
+ if (!data.issueCreate.success) return JSON.stringify({ error: 'issueCreate failed' })
590
+ const i = data.issueCreate.issue
591
+ return JSON.stringify({
592
+ id: i.id,
593
+ identifier: i.identifier,
594
+ title: i.title,
595
+ state: i.state?.name,
596
+ url: i.url,
597
+ }, null, 2)
598
+ },
599
+ }),
600
+ )
601
+
602
+ register(
603
+ defineTool({
604
+ name: 'linear_issue_update',
605
+ description:
606
+ 'Update an existing Linear issue by id (UUID) or identifier. Any of: state name, description, assignee id, label ids, priority (0-4), title.',
607
+ parameters: {
608
+ id: { type: 'string', required: true, description: 'Issue id (UUID) or identifier' },
609
+ title: { type: 'string', description: 'New title' },
610
+ description: { type: 'string', description: 'New description (markdown)' },
611
+ state: { type: 'string', description: 'New state name, e.g. "Done", "In Progress"' },
612
+ assignee: { type: 'string', description: 'New assignee user id (UUID); empty string to unassign' },
613
+ labels: { type: 'array', items: { type: 'string' }, description: 'Label ids to set (replaces existing)' },
614
+ priority: { type: 'number', description: 'Priority 0-4' },
615
+ },
616
+ output: {
617
+ schema: { type: 'string' },
618
+ render: (_a, v) => [{ type: 'text', text: v }],
619
+ },
620
+ async execute(args) {
621
+ const issueId = await findIssueId(key(), args.id)
622
+ if (!issueId) return JSON.stringify({ error: `Issue not found: ${args.id}` })
623
+
624
+ // IssueUpdateInput is flat (stateId/assigneeId/labelIds are UUIDs).
625
+ // A state change needs the issue's team to resolve the state name.
626
+ const input = { id: issueId }
627
+ if (args.title !== undefined) input.title = args.title
628
+ if (args.description !== undefined) input.description = args.description
629
+ if (args.assignee !== undefined) input.assigneeId = args.assignee || null
630
+ if (args.labels !== undefined) input.labelIds = args.labels
631
+ if (args.priority !== undefined) input.priority = args.priority
632
+ if (args.state !== undefined) {
633
+ const issueData = await gql(key(), `
634
+ query($id: String!) {
635
+ issue: issue(id: $id) { id team { id } }
636
+ }
637
+ `, { id: issueId })
638
+ const teamId = issueData.issue?.team?.id
639
+ if (!teamId) return JSON.stringify({ error: `Could not resolve team for issue ${args.id}` })
640
+ input.stateId = await resolveStateId(key(), teamId, args.state)
641
+ }
642
+
643
+ const data = await gql(key(), `
644
+ mutation($input: IssueUpdateInput!) {
645
+ issueUpdate(input: $input) {
646
+ success issue { ${ISSUE_FIELDS} }
647
+ }
648
+ }
649
+ `, { input })
650
+ if (!data.issueUpdate.success) return JSON.stringify({ error: 'issueUpdate failed' })
651
+ const i = data.issueUpdate.issue
652
+ return JSON.stringify({
653
+ id: i.id,
654
+ identifier: i.identifier,
655
+ title: i.title,
656
+ state: i.state?.name,
657
+ assignee: i.assignee?.name,
658
+ url: i.url,
659
+ }, null, 2)
660
+ },
661
+ }),
662
+ )
663
+
664
+ register(
665
+ defineTool({
666
+ name: 'linear_issue_archive',
667
+ description:
668
+ 'Archive a Linear issue by id (UUID) or identifier. Archived issues leave the team\'s default views. To bring an archived issue back, use the separate linear_issue_unarchive tool — this tool takes no unarchive argument.',
669
+ parameters: {
670
+ id: { type: 'string', required: true, description: 'Issue id (UUID) or identifier' },
671
+ },
672
+ output: {
673
+ schema: { type: 'string' },
674
+ render: (_a, v) => [{ type: 'text', text: v }],
675
+ },
676
+ async execute(args) {
677
+ return archiveIssue(key(), args.id, false)
678
+ },
679
+ }),
680
+ )
681
+
682
+ register(
683
+ defineTool({
684
+ name: 'linear_issue_unarchive',
685
+ description: 'Unarchive a previously archived Linear issue by id (UUID) or identifier, returning it to the team\'s views.',
686
+ parameters: {
687
+ id: { type: 'string', required: true, description: 'Issue id (UUID) or identifier' },
688
+ },
689
+ output: {
690
+ schema: { type: 'string' },
691
+ render: (_a, v) => [{ type: 'text', text: v }],
692
+ },
693
+ async execute(args) {
694
+ return archiveIssue(key(), args.id, true)
695
+ },
696
+ }),
697
+ )
698
+
699
+ // -- Comments ---------------------------------------------------------------
700
+
701
+ register(
702
+ defineTool({
703
+ name: 'linear_comment_add',
704
+ description: 'Add a comment to a Linear issue (by id or identifier).',
705
+ parameters: {
706
+ issue: { type: 'string', required: true, description: 'Issue id or identifier' },
707
+ body: { type: 'string', required: true, description: 'Comment body (markdown)' },
708
+ },
709
+ output: {
710
+ schema: { type: 'string' },
711
+ render: (_a, v) => [{ type: 'text', text: v }],
712
+ },
713
+ async execute(args) {
714
+ const issueId = await findIssueId(key(), args.issue)
715
+ if (!issueId) return JSON.stringify({ error: `Issue not found: ${args.issue}` })
716
+ const data = await gql(key(), `
717
+ mutation($input: CommentCreateInput!) {
718
+ commentCreate(input: $input) {
719
+ success comment { ${COMMENT_FIELDS} }
720
+ }
721
+ }
722
+ `, { input: { issueId, body: args.body } })
723
+ if (!data.commentCreate.success) return JSON.stringify({ error: 'commentCreate failed' })
724
+ return JSON.stringify({ id: data.commentCreate.comment.id, body: data.commentCreate.comment.body }, null, 2)
725
+ },
726
+ }),
727
+ )
728
+
729
+ register(
730
+ defineTool({
731
+ name: 'linear_comment_list',
732
+ description: 'List comments on a Linear issue (by id or identifier), newest first. Cursor pagination: pass the previous page\'s pageInfo.endCursor as `after` to get the next page; the response includes pageInfo { hasNextPage, endCursor }.',
733
+ parameters: {
734
+ issue: { type: 'string', required: true, description: 'Issue id or identifier' },
735
+ limit: { type: 'number', description: 'Max comments (default 20, max 50)' },
736
+ after: { type: 'string', description: 'Cursor from a previous page (pageInfo.endCursor) to fetch the next page' },
737
+ },
738
+ output: {
739
+ schema: { type: 'string' },
740
+ render: (_a, v) => [{ type: 'text', text: v }],
741
+ },
742
+ async execute(args) {
743
+ const limit = Math.min(Math.max(1, Math.floor(args.limit ?? 20)), 50)
744
+ // Optional cursor. Kept as a variable (`$after`), never interpolated.
745
+ const after = typeof args.after === 'string' && args.after.trim() ? args.after : undefined
746
+ const issueId = await findIssueId(key(), args.issue)
747
+ if (!issueId) return JSON.stringify({ error: `Issue not found: ${args.issue}` })
748
+ const vars = { id: issueId }
749
+ if (after !== undefined) vars.after = after
750
+ const varDecl = after !== undefined ? 'query($id: String!, $after: String)' : 'query($id: String!)'
751
+ // One shared argument list: the cursor is declared only when it is also
752
+ // passed, so a call with no `after` sends exactly the pre-pagination document.
753
+ const argList = after !== undefined ? 'after: $after' : ''
754
+ const data = await gql(key(), `
755
+ ${varDecl} {
756
+ issue: issue(id: $id) {
757
+ id
758
+ comments(first: ${limit}${argList ? `, ${argList}` : ''}) {
759
+ nodes { ${COMMENT_FIELDS} }
760
+ ${PAGE_INFO_FIELDS}
761
+ }
762
+ }
763
+ }
764
+ `, vars)
765
+ if (!data.issue) return JSON.stringify({ error: `Issue not found: ${args.issue}` })
766
+ return JSON.stringify({
767
+ issue: args.issue,
768
+ count: data.issue.comments.nodes.length,
769
+ comments: data.issue.comments.nodes,
770
+ pageInfo: normalizePageInfo(data.issue.comments.pageInfo),
771
+ }, null, 2)
772
+ },
773
+ }),
774
+ )
775
+
776
+ register(
777
+ defineTool({
778
+ name: 'linear_comment_update',
779
+ description: 'Update the body of an existing comment on a Linear issue. `id` is the comment UUID (from linear_comment_list); `body` is the new markdown body.',
780
+ parameters: {
781
+ id: { type: 'string', required: true, description: 'Comment id (UUID)' },
782
+ body: { type: 'string', required: true, description: 'New comment body (markdown)' },
783
+ },
784
+ output: {
785
+ schema: { type: 'string' },
786
+ render: (_a, v) => [{ type: 'text', text: v }],
787
+ },
788
+ async execute(args) {
789
+ // CommentUpdateInput carries only the changed fields; the comment UUID is
790
+ // a top-level `id` argument on the mutation (verified by introspection).
791
+ const data = await gql(key(), `
792
+ mutation($id: String!, $input: CommentUpdateInput!) {
793
+ commentUpdate(id: $id, input: $input) {
794
+ success comment { ${COMMENT_FIELDS} }
795
+ }
796
+ }
797
+ `, { id: args.id, input: { body: args.body } })
798
+ if (!data.commentUpdate.success) return JSON.stringify({ error: 'commentUpdate failed' })
799
+ const c = data.commentUpdate.comment
800
+ return JSON.stringify({ id: c?.id, body: c?.body }, null, 2)
801
+ },
802
+ }),
803
+ )
804
+
805
+ register(
806
+ defineTool({
807
+ name: 'linear_comment_delete',
808
+ description: 'Delete a comment from a Linear issue by its comment id (UUID, from linear_comment_list). This is destructive and cannot be undone.',
809
+ parameters: {
810
+ id: { type: 'string', required: true, description: 'Comment id (UUID)' },
811
+ },
812
+ output: {
813
+ schema: { type: 'string' },
814
+ render: (_a, v) => [{ type: 'text', text: v }],
815
+ },
816
+ async execute(args) {
817
+ // commentDelete takes a bare `id` argument — there is NO CommentDeleteInput
818
+ // type in the Linear schema (verified by introspection). Its payload is
819
+ // DeletePayload { success entityId lastSyncId } — no entity object.
820
+ const data = await gql(key(), `
821
+ mutation($id: String!) {
822
+ commentDelete(id: $id) {
823
+ success entityId
824
+ }
825
+ }
826
+ `, { id: args.id })
827
+ if (!data.commentDelete.success) return JSON.stringify({ error: 'commentDelete failed' })
828
+ return JSON.stringify({ ok: true, deletedId: data.commentDelete.entityId ?? args.id }, null, 2)
829
+ },
830
+ }),
831
+ )
832
+
833
+ // -- Relations --------------------------------------------------------------
834
+
835
+ register(
836
+ defineTool({
837
+ name: 'linear_relation_add',
838
+ description:
839
+ 'Link a related issue to an issue. Both given by id or identifier. `type` is the relation type (default "related").',
840
+ parameters: {
841
+ issue: { type: 'string', required: true, description: 'Source issue id or identifier' },
842
+ related: { type: 'string', required: true, description: 'Related issue id or identifier' },
843
+ type: { type: 'string', description: 'Relation type: related, blockedBy, blocks, parent, child (default "related")' },
844
+ },
845
+ output: {
846
+ schema: { type: 'string' },
847
+ render: (_a, v) => [{ type: 'text', text: v }],
848
+ },
849
+ async execute(args) {
850
+ const type = args.type ?? 'related'
851
+ const VALID_RELATION_TYPES = ['related', 'blockedBy', 'blocks', 'parent', 'child']
852
+ if (!VALID_RELATION_TYPES.includes(type)) {
853
+ return JSON.stringify({ error: `invalid relation type: ${type}, expected one of: ${VALID_RELATION_TYPES.join(', ')}` })
854
+ }
855
+ const [issueId, relatedId] = await Promise.all([
856
+ findIssueId(key(), args.issue),
857
+ findIssueId(key(), args.related),
858
+ ])
859
+ if (!issueId) return JSON.stringify({ error: `Issue not found: ${args.issue}` })
860
+ if (!relatedId) return JSON.stringify({ error: `Issue not found: ${args.related}` })
861
+ const data = await gql(key(), `
862
+ mutation($input: IssueRelationCreateInput!) {
863
+ issueRelationCreate(input: $input) {
864
+ success relation { id }
865
+ }
866
+ }
867
+ `, { input: { issueId, relatedIssueId: relatedId, type } })
868
+ if (!data.issueRelationCreate.success) return JSON.stringify({ error: 'issueRelationCreate failed' })
869
+ return JSON.stringify({ ok: true, relationId: data.issueRelationCreate.relation.id }, null, 2)
870
+ },
871
+ }),
872
+ )
873
+
874
+ // -- Teams / states / labels / projects -------------------------------------
875
+
876
+ register(
877
+ defineTool({
878
+ name: 'linear_team_list',
879
+ description: 'List all Linear teams (key, name, description). Cursor pagination: pass the previous page\'s pageInfo.endCursor as `after` to get the next page; the response includes pageInfo { hasNextPage, endCursor }.',
880
+ parameters: {
881
+ after: { type: 'string', description: 'Cursor from a previous page (pageInfo.endCursor) to fetch the next page' },
882
+ },
883
+ output: {
884
+ schema: { type: 'string' },
885
+ render: (_a, v) => [{ type: 'text', text: v }],
886
+ },
887
+ async execute(args) {
888
+ // Optional cursor. Kept as a variable (`$after`), never interpolated.
889
+ const after = typeof args.after === 'string' && args.after.trim() ? args.after : undefined
890
+ const vars = {}
891
+ if (after !== undefined) vars.after = after
892
+ const varDecl = after !== undefined ? 'query($after: String)' : 'query'
893
+ // One shared argument list: the cursor is declared only when it is also
894
+ // passed, so a call with no `after` sends exactly the pre-pagination document.
895
+ const argList = [after !== undefined ? 'after: $after' : null].filter(Boolean).join(', ')
896
+ const data = await gql(key(), `
897
+ ${varDecl} {
898
+ teams(first: 100${argList ? `, ${argList}` : ''}) {
899
+ nodes { ${TEAM_FIELDS} }
900
+ ${PAGE_INFO_FIELDS}
901
+ }
902
+ }
903
+ `, vars)
904
+ return JSON.stringify({ count: data.teams.nodes.length, teams: data.teams.nodes, pageInfo: normalizePageInfo(data.teams.pageInfo) }, null, 2)
905
+ },
906
+ }),
907
+ )
908
+
909
+ register(
910
+ defineTool({
911
+ name: 'linear_state_list',
912
+ description: 'List workflow states for a team (e.g. Todo, In Progress, Done). Cursor pagination: pass the previous page\'s pageInfo.endCursor as `after` to get the next page; the response includes pageInfo { hasNextPage, endCursor }.',
913
+ parameters: {
914
+ team: { type: 'string', required: true, description: 'Team key, e.g. "APT"' },
915
+ after: { type: 'string', description: 'Cursor from a previous page (pageInfo.endCursor) to fetch the next page' },
916
+ },
917
+ output: {
918
+ schema: { type: 'string' },
919
+ render: (_a, v) => [{ type: 'text', text: v }],
920
+ },
921
+ async execute(args) {
922
+ // Optional cursor. Kept as a variable (`$after`), never interpolated.
923
+ const after = typeof args.after === 'string' && args.after.trim() ? args.after : undefined
924
+ // Resolve the team to a UUID via the PLURAL `teams` field (the singular
925
+ // `team` field takes only a UUID id), then list that team's states via
926
+ // the top-level `workflowStates` field filtered by team id.
927
+ const teamId = await resolveTeamId(key(), args.team)
928
+ const vars = { teamId }
929
+ if (after !== undefined) vars.after = after
930
+ const varDecl = after !== undefined ? 'query($teamId: String!, $after: String)' : 'query($teamId: String!)'
931
+ // One shared argument list: the cursor is declared only when it is also
932
+ // passed, so a call with no `after` sends exactly the pre-pagination document.
933
+ const argList = [after !== undefined ? 'after: $after' : null].filter(Boolean).join(', ')
934
+ const data = await gql(key(), `
935
+ ${varDecl} {
936
+ workflowStates(filter: { team: { id: { eq: $teamId } } }${argList ? `, ${argList}` : ''}) {
937
+ nodes { ${STATE_FIELDS} }
938
+ ${PAGE_INFO_FIELDS}
939
+ }
940
+ }
941
+ `, vars)
942
+ return JSON.stringify({ team: args.team, states: data.workflowStates.nodes, pageInfo: normalizePageInfo(data.workflowStates.pageInfo) }, null, 2)
943
+ },
944
+ }),
945
+ )
946
+
947
+ register(
948
+ defineTool({
949
+ name: 'linear_label_list',
950
+ description: 'List labels for a team. Cursor pagination: pass the previous page\'s pageInfo.endCursor as `after` to get the next page; the response includes pageInfo { hasNextPage, endCursor }.',
951
+ parameters: {
952
+ team: { type: 'string', required: true, description: 'Team key, e.g. "APT"' },
953
+ after: { type: 'string', description: 'Cursor from a previous page (pageInfo.endCursor) to fetch the next page' },
954
+ },
955
+ output: {
956
+ schema: { type: 'string' },
957
+ render: (_a, v) => [{ type: 'text', text: v }],
958
+ },
959
+ async execute(args) {
960
+ // Optional cursor. Kept as a variable (`$after`), never interpolated.
961
+ const after = typeof args.after === 'string' && args.after.trim() ? args.after : undefined
962
+ const teamId = await resolveTeamId(key(), args.team)
963
+ const vars = { teamId }
964
+ if (after !== undefined) vars.after = after
965
+ const varDecl = after !== undefined ? 'query($teamId: String!, $after: String)' : 'query($teamId: String!)'
966
+ // One shared argument list: the cursor is declared only when it is also
967
+ // passed, so a call with no `after` sends exactly the pre-pagination document.
968
+ const argList = [after !== undefined ? 'after: $after' : null].filter(Boolean).join(', ')
969
+ const data = await gql(key(), `
970
+ ${varDecl} {
971
+ issueLabels(filter: { team: { id: { eq: $teamId } } }${argList ? `, ${argList}` : ''}) {
972
+ nodes { ${LABEL_FIELDS} }
973
+ ${PAGE_INFO_FIELDS}
974
+ }
975
+ }
976
+ `, vars)
977
+ return JSON.stringify({ team: args.team, labels: data.issueLabels.nodes, pageInfo: normalizePageInfo(data.issueLabels.pageInfo) }, null, 2)
978
+ },
979
+ }),
980
+ )
981
+
982
+ register(
983
+ defineTool({
984
+ name: 'linear_project_list',
985
+ description: 'List Linear projects (name, description, state, teams). Cursor pagination: pass the previous page\'s pageInfo.endCursor as `after` to get the next page; the response includes pageInfo { hasNextPage, endCursor }.',
986
+ parameters: {
987
+ limit: { type: 'number', description: 'Max projects (default 20, max 50)' },
988
+ after: { type: 'string', description: 'Cursor from a previous page (pageInfo.endCursor) to fetch the next page' },
989
+ },
990
+ output: {
991
+ schema: { type: 'string' },
992
+ render: (_a, v) => [{ type: 'text', text: v }],
993
+ },
994
+ async execute(args) {
995
+ const limit = Math.min(Math.max(1, Math.floor(args.limit ?? 20)), 50)
996
+ // Optional cursor. Kept as a variable (`$after`), never interpolated.
997
+ const after = typeof args.after === 'string' && args.after.trim() ? args.after : undefined
998
+ const vars = {}
999
+ if (after !== undefined) vars.after = after
1000
+ const varDecl = after !== undefined ? 'query($after: String)' : 'query'
1001
+ // One shared argument list: the cursor is declared only when it is also
1002
+ // passed, so a call with no `after` sends exactly the pre-pagination document.
1003
+ const argList = [after !== undefined ? 'after: $after' : null].filter(Boolean).join(', ')
1004
+ const data = await gql(key(), `
1005
+ ${varDecl} {
1006
+ projects(first: ${limit}${argList ? `, ${argList}` : ''}) {
1007
+ nodes { ${PROJECT_FIELDS} }
1008
+ ${PAGE_INFO_FIELDS}
1009
+ }
1010
+ }
1011
+ `, vars)
1012
+ return JSON.stringify({ count: data.projects.nodes.length, projects: data.projects.nodes, pageInfo: normalizePageInfo(data.projects.pageInfo) }, null, 2)
1013
+ },
1014
+ }),
1015
+ )
1016
+
1017
+ register(
1018
+ defineTool({
1019
+ name: 'linear_project_create',
1020
+ description: 'Create a Linear project. `name` required; optional description and team keys to include.',
1021
+ parameters: {
1022
+ name: { type: 'string', required: true, description: 'Project name' },
1023
+ description: { type: 'string', description: 'Project description' },
1024
+ teams: { type: 'array', items: { type: 'string' }, description: 'Team keys to include in the project' },
1025
+ },
1026
+ output: {
1027
+ schema: { type: 'string' },
1028
+ render: (_a, v) => [{ type: 'text', text: v }],
1029
+ },
1030
+ async execute(args) {
1031
+ const input = { name: args.name }
1032
+ if (args.description) input.description = args.description
1033
+ if (args.teams?.length) {
1034
+ // ProjectCreateInput takes flat team UUIDs (teamIds), not nested keys.
1035
+ input.teamIds = await Promise.all(args.teams.map((t) => resolveTeamId(key(), t)))
1036
+ }
1037
+ const data = await gql(key(), `
1038
+ mutation($input: ProjectCreateInput!) {
1039
+ projectCreate(input: $input) {
1040
+ success project { ${PROJECT_FIELDS} }
1041
+ }
1042
+ }
1043
+ `, { input })
1044
+ if (!data.projectCreate.success) return JSON.stringify({ error: 'projectCreate failed' })
1045
+ return JSON.stringify(data.projectCreate.project, null, 2)
1046
+ },
1047
+ }),
1048
+ )
1049
+
1050
+ register(
1051
+ defineTool({
1052
+ name: 'linear_project_update',
1053
+ description:
1054
+ 'Update a Linear project by id (UUID) or exact name. Any of: name, description, content, icon, color, team keys, start date, target date, priority.',
1055
+ parameters: {
1056
+ id: { type: 'string', required: true, description: 'Project id (UUID) or exact project name' },
1057
+ name: { type: 'string', description: 'New project name' },
1058
+ description: { type: 'string', description: 'New project description' },
1059
+ content: { type: 'string', description: 'New project content (markdown)' },
1060
+ icon: { type: 'string', description: 'Project icon identifier, e.g. "Rocket"' },
1061
+ color: { type: 'string', description: 'Hex color, e.g. "#ff0000"' },
1062
+ teams: { type: 'array', items: { type: 'string' }, description: 'Team keys to set (replaces existing)' },
1063
+ startDate: { type: 'string', description: 'Start date, YYYY-MM-DD' },
1064
+ targetDate: { type: 'string', description: 'Target date, YYYY-MM-DD' },
1065
+ priority: { type: 'number', description: 'Priority 0 (none) to 4 (urgent)' },
1066
+ },
1067
+ output: {
1068
+ schema: { type: 'string' },
1069
+ render: (_a, v) => [{ type: 'text', text: v }],
1070
+ },
1071
+ async execute(args) {
1072
+ // A name that matches more than one project never reaches the mutation —
1073
+ // resolveLinearProjectId answers with a clean ambiguity error instead.
1074
+ const resolved = await resolveLinearProjectId(key(), args.id)
1075
+ if (resolved.error) return JSON.stringify({ error: resolved.error })
1076
+ const projectId = resolved.projectId
1077
+ if (!projectId) return JSON.stringify({ error: `Project not found: ${args.id}` })
1078
+
1079
+ // ProjectUpdateInput is flat (teamIds are UUIDs), and the mutation takes
1080
+ // the project UUID as a top-level `id` argument alongside `input`.
1081
+ const input = {}
1082
+ if (args.name !== undefined) input.name = args.name
1083
+ if (args.description !== undefined) input.description = args.description
1084
+ if (args.content !== undefined) input.content = args.content
1085
+ if (args.icon !== undefined) input.icon = args.icon
1086
+ if (args.color !== undefined) input.color = args.color
1087
+ if (args.startDate !== undefined) input.startDate = args.startDate
1088
+ if (args.targetDate !== undefined) input.targetDate = args.targetDate
1089
+ if (args.priority !== undefined) input.priority = args.priority
1090
+ if (args.teams !== undefined) {
1091
+ input.teamIds = await Promise.all((args.teams ?? []).map((t) => resolveTeamId(key(), t)))
1092
+ }
1093
+
1094
+ const data = await gql(key(), `
1095
+ mutation($id: String!, $input: ProjectUpdateInput!) {
1096
+ projectUpdate(id: $id, input: $input) {
1097
+ success project { ${PROJECT_FIELDS} }
1098
+ }
1099
+ }
1100
+ `, { id: projectId, input })
1101
+ if (!data.projectUpdate.success) return JSON.stringify({ error: 'projectUpdate failed' })
1102
+ return JSON.stringify(data.projectUpdate.project, null, 2)
1103
+ },
1104
+ }),
1105
+ )
1106
+
1107
+ register(
1108
+ defineTool({
1109
+ name: 'linear_label_create',
1110
+ description: 'Create a label for a team.',
1111
+ parameters: {
1112
+ team: { type: 'string', required: true, description: 'Team key, e.g. "APT"' },
1113
+ name: { type: 'string', required: true, description: 'Label name' },
1114
+ color: { type: 'string', description: 'Hex color, e.g. "#ff0000"' },
1115
+ description: { type: 'string', description: 'Label description' },
1116
+ },
1117
+ output: {
1118
+ schema: { type: 'string' },
1119
+ render: (_a, v) => [{ type: 'text', text: v }],
1120
+ },
1121
+ async execute(args) {
1122
+ // LabelCreateInput takes a flat team UUID (teamId), not a nested key.
1123
+ const teamId = await resolveTeamId(key(), args.team)
1124
+ const data = await gql(key(), `
1125
+ mutation($input: IssueLabelCreateInput!) {
1126
+ issueLabelCreate(input: $input) {
1127
+ success label { ${LABEL_FIELDS} }
1128
+ }
1129
+ }
1130
+ `, {
1131
+ input: {
1132
+ teamId,
1133
+ name: args.name,
1134
+ ...(args.color ? { color: args.color } : {}),
1135
+ ...(args.description ? { description: args.description } : {}),
1136
+ },
1137
+ })
1138
+ if (!data.issueLabelCreate.success) return JSON.stringify({ error: 'issueLabelCreate failed' })
1139
+ return JSON.stringify(data.issueLabelCreate.label, null, 2)
1140
+ },
1141
+ }),
1142
+ )
1143
+
1144
+ // -- Cycles -----------------------------------------------------------------
1145
+
1146
+ register(
1147
+ defineTool({
1148
+ name: 'linear_cycle_list',
1149
+ description: 'List cycles for a team. Returns up to `limit` most recent cycles (default 20, max 50). Cursor pagination: pass the previous page\'s pageInfo.endCursor as `after` to get the next page; the response includes pageInfo { hasNextPage, endCursor }.',
1150
+ parameters: {
1151
+ team: { type: 'string', required: true, description: 'Team key, e.g. "APT"' },
1152
+ limit: { type: 'number', description: 'Max cycles (default 20, max 50)' },
1153
+ after: { type: 'string', description: 'Cursor from a previous page (pageInfo.endCursor) to fetch the next page' },
1154
+ },
1155
+ output: {
1156
+ schema: { type: 'string' },
1157
+ render: (_a, v) => [{ type: 'text', text: v }],
1158
+ },
1159
+ async execute(args) {
1160
+ const limit = Math.min(Math.max(1, Math.floor(args.limit ?? 20)), 50)
1161
+ // Optional cursor. Kept as a variable (`$after`), never interpolated.
1162
+ const after = typeof args.after === 'string' && args.after.trim() ? args.after : undefined
1163
+ const teamId = await resolveTeamId(key(), args.team)
1164
+ const vars = { teamId }
1165
+ if (after !== undefined) vars.after = after
1166
+ const varDecl = after !== undefined ? 'query($teamId: String!, $after: String)' : 'query($teamId: String!)'
1167
+ // One shared argument list: the cursor is declared only when it is also
1168
+ // passed, so a call with no `after` sends exactly the pre-pagination document.
1169
+ const argList = [after !== undefined ? 'after: $after' : null].filter(Boolean).join(', ')
1170
+ const data = await gql(key(), `
1171
+ ${varDecl} {
1172
+ cycles(first: ${limit}, filter: { team: { id: { eq: $teamId } } }${argList ? `, ${argList}` : ''}) {
1173
+ nodes { ${CYCLE_FIELDS} }
1174
+ ${PAGE_INFO_FIELDS}
1175
+ }
1176
+ }
1177
+ `, vars)
1178
+ const cycles = data.cycles.nodes.map((c) => ({
1179
+ id: c.id,
1180
+ number: c.number,
1181
+ name: c.name,
1182
+ startsAt: c.startsAt,
1183
+ endsAt: c.endsAt,
1184
+ completedAt: c.completedAt,
1185
+ }))
1186
+ return JSON.stringify({ team: args.team, count: cycles.length, cycles, pageInfo: normalizePageInfo(data.cycles.pageInfo) }, null, 2)
1187
+ },
1188
+ }),
1189
+ )
1190
+
1191
+ register(
1192
+ defineTool({
1193
+ name: 'linear_cycle_get',
1194
+ description: 'Get a single cycle by its id (UUID). Returns full details including team, progress, and dates.',
1195
+ parameters: {
1196
+ id: { type: 'string', required: true, description: 'Cycle id (UUID)' },
1197
+ },
1198
+ output: {
1199
+ schema: { type: 'string' },
1200
+ render: (_a, v) => [{ type: 'text', text: v }],
1201
+ },
1202
+ async execute(args) {
1203
+ const data = await gql(key(), `
1204
+ query($id: String!) {
1205
+ cycle: cycle(id: $id) {
1206
+ ${CYCLE_FIELDS}
1207
+ }
1208
+ }
1209
+ `, { id: args.id })
1210
+ if (!data.cycle) return JSON.stringify({ error: `Cycle not found: ${args.id}` })
1211
+ return JSON.stringify(data.cycle, null, 2)
1212
+ },
1213
+ }),
1214
+ )
1215
+
1216
+ // -- Users ------------------------------------------------------------------
1217
+
1218
+ register(
1219
+ defineTool({
1220
+ name: 'linear_user_list',
1221
+ description: 'List users in the workspace. Returns up to `limit` users (default 20, max 50). Cursor pagination: pass the previous page\'s pageInfo.endCursor as `after` to get the next page; the response includes pageInfo { hasNextPage, endCursor }.',
1222
+ parameters: {
1223
+ limit: { type: 'number', description: 'Max users (default 20, max 50)' },
1224
+ after: { type: 'string', description: 'Cursor from a previous page (pageInfo.endCursor) to fetch the next page' },
1225
+ },
1226
+ output: {
1227
+ schema: { type: 'string' },
1228
+ render: (_a, v) => [{ type: 'text', text: v }],
1229
+ },
1230
+ async execute(args) {
1231
+ const limit = Math.min(Math.max(1, Math.floor(args.limit ?? 20)), 50)
1232
+ // Optional cursor. Kept as a variable (`$after`), never interpolated.
1233
+ const after = typeof args.after === 'string' && args.after.trim() ? args.after : undefined
1234
+ const vars = {}
1235
+ if (after !== undefined) vars.after = after
1236
+ const varDecl = after !== undefined ? 'query($after: String)' : 'query'
1237
+ // One shared argument list: the cursor is declared only when it is also
1238
+ // passed, so a call with no `after` sends exactly the pre-pagination document.
1239
+ const argList = [after !== undefined ? 'after: $after' : null].filter(Boolean).join(', ')
1240
+ const data = await gql(key(), `
1241
+ ${varDecl} {
1242
+ users(first: ${limit}${argList ? `, ${argList}` : ''}) {
1243
+ nodes { ${USER_FIELDS} }
1244
+ ${PAGE_INFO_FIELDS}
1245
+ }
1246
+ }
1247
+ `, vars)
1248
+ return JSON.stringify({ count: data.users.nodes.length, users: data.users.nodes, pageInfo: normalizePageInfo(data.users.pageInfo) }, null, 2)
1249
+ },
1250
+ }),
1251
+ )
1252
+
1253
+ register(
1254
+ defineTool({
1255
+ name: 'linear_user_get',
1256
+ description: 'Get a single user by id (UUID) or email. Returns full user details.',
1257
+ parameters: {
1258
+ id: { type: 'string', required: true, description: 'User id (UUID) or email address' },
1259
+ },
1260
+ output: {
1261
+ schema: { type: 'string' },
1262
+ render: (_a, v) => [{ type: 'text', text: v }],
1263
+ },
1264
+ async execute(args) {
1265
+ let user
1266
+ if (UUID_RE.test(args.id)) {
1267
+ const data = await gql(key(), `
1268
+ query($id: String!) {
1269
+ user: user(id: $id) { ${USER_FIELDS} }
1270
+ }
1271
+ `, { id: args.id })
1272
+ user = data.user
1273
+ } else {
1274
+ const data = await gql(key(), `
1275
+ query($email: String!) {
1276
+ users(filter: { email: { eq: $email } }) {
1277
+ nodes { ${USER_FIELDS} }
1278
+ }
1279
+ }
1280
+ `, { email: args.id })
1281
+ user = data.users.nodes[0] ?? null
1282
+ }
1283
+ if (!user) return JSON.stringify({ error: `User not found: ${args.id}` })
1284
+ return JSON.stringify(user, null, 2)
1285
+ },
1286
+ }),
1287
+ )
1288
+
1289
+ // -- Single-item getters (project / label / team) ---------------------------
1290
+
1291
+ register(
1292
+ defineTool({
1293
+ name: 'linear_project_get',
1294
+ description: 'Get a single project by its id (UUID). Returns full project details including teams.',
1295
+ parameters: {
1296
+ id: { type: 'string', required: true, description: 'Project id (UUID)' },
1297
+ },
1298
+ output: {
1299
+ schema: { type: 'string' },
1300
+ render: (_a, v) => [{ type: 'text', text: v }],
1301
+ },
1302
+ async execute(args) {
1303
+ const data = await gql(key(), `
1304
+ query($id: String!) {
1305
+ project: project(id: $id) {
1306
+ ${PROJECT_FIELDS}
1307
+ }
1308
+ }
1309
+ `, { id: args.id })
1310
+ if (!data.project) return JSON.stringify({ error: `Project not found: ${args.id}` })
1311
+ return JSON.stringify(data.project, null, 2)
1312
+ },
1313
+ }),
1314
+ )
1315
+
1316
+ register(
1317
+ defineTool({
1318
+ name: 'linear_label_get',
1319
+ description: 'Get a single label by its id (UUID). Returns full label details.',
1320
+ parameters: {
1321
+ id: { type: 'string', required: true, description: 'Label id (UUID)' },
1322
+ },
1323
+ output: {
1324
+ schema: { type: 'string' },
1325
+ render: (_a, v) => [{ type: 'text', text: v }],
1326
+ },
1327
+ async execute(args) {
1328
+ const data = await gql(key(), `
1329
+ query($id: String!) {
1330
+ label: issueLabel(id: $id) {
1331
+ ${LABEL_FIELDS}
1332
+ }
1333
+ }
1334
+ `, { id: args.id })
1335
+ if (!data.label) return JSON.stringify({ error: `Label not found: ${args.id}` })
1336
+ return JSON.stringify(data.label, null, 2)
1337
+ },
1338
+ }),
1339
+ )
1340
+
1341
+ register(
1342
+ defineTool({
1343
+ name: 'linear_team_get',
1344
+ description: 'Get a single team by its id (UUID) or key (e.g. "APT"). Returns full team details.',
1345
+ parameters: {
1346
+ id: { type: 'string', required: true, description: 'Team id (UUID) or key like "APT"' },
1347
+ },
1348
+ output: {
1349
+ schema: { type: 'string' },
1350
+ render: (_a, v) => [{ type: 'text', text: v }],
1351
+ },
1352
+ async execute(args) {
1353
+ let team
1354
+ if (UUID_RE.test(args.id)) {
1355
+ const data = await gql(key(), `
1356
+ query($id: String!) {
1357
+ team: team(id: $id) { ${TEAM_FIELDS} }
1358
+ }
1359
+ `, { id: args.id })
1360
+ team = data.team
1361
+ } else {
1362
+ // The singular `team` field takes only a UUID id; a key/name must be
1363
+ // resolved through the PLURAL `teams` field (which accepts a filter).
1364
+ const data = await gql(key(), `
1365
+ query($key: String!) {
1366
+ teams(first: 10, filter: { key: { eq: $key } }) {
1367
+ nodes { ${TEAM_FIELDS} }
1368
+ }
1369
+ }
1370
+ `, { key: args.id })
1371
+ const nodes = data.teams?.nodes ?? []
1372
+ if (nodes.length !== 1) return JSON.stringify({ error: `Team not found: ${args.id}` })
1373
+ team = nodes[0]
1374
+ }
1375
+ if (!team) return JSON.stringify({ error: `Team not found: ${args.id}` })
1376
+ return JSON.stringify(team, null, 2)
1377
+ },
1378
+ }),
1379
+ )
1380
+
1381
+ // -- Direct GraphQL escape hatch -------------------------------------------
1382
+ //
1383
+ // The one tool that is not a hand-modelled endpoint: it forwards the caller's
1384
+ // own GraphQL document to the SAME fixed endpoint (API_URL) with the SAME
1385
+ // configured key, through the same gql() client and the same { error } guard
1386
+ // every other tool runs under. There is no second endpoint, no extra config,
1387
+ // and no credential of its own.
1388
+ //
1389
+ // Queries AND mutations are allowed — that is the point of the escape hatch —
1390
+ // so what a call may actually do is bounded only by the permissions attached to
1391
+ // the configured Linear API key, exactly as they are for every other tool here.
1392
+ register(
1393
+ defineTool({
1394
+ name: 'linear_graphql',
1395
+ description:
1396
+ 'Direct GraphQL escape hatch: send an arbitrary GraphQL document (query OR mutation) straight to the Linear API and get the raw `data` back as JSON. Use it for anything the named linear_* tools do not cover — coverage is whatever the Linear GraphQL API exposes to your key, so this is NOT a substitute for named-tool parity and nothing is projected or normalised. `query` is required and must be non-blank; `variables` is an optional object of GraphQL variable values (prefer variables over inlining values into the document). Both queries and mutations are sent as-is: what they may read or change is governed by your Linear API key permissions, so a mutation really writes.',
1397
+ parameters: {
1398
+ query: { type: 'string', required: true, description: 'The GraphQL document to execute (a query or a mutation)' },
1399
+ // Explicit properties keep common variable shapes documented; openness
1400
+ // stays true because the document decides which variables exist.
1401
+ variables: {
1402
+ type: 'object',
1403
+ additionalProperties: true,
1404
+ description: 'Optional GraphQL variables object, keyed by variable name (e.g. { "id": "<uuid>" })',
1405
+ },
1406
+ },
1407
+ output: {
1408
+ schema: { type: 'string' },
1409
+ render: (_a, v) => [{ type: 'text', text: v }],
1410
+ },
1411
+ async execute(args) {
1412
+ // The parameter schema enforces the string; this is the non-blank half of
1413
+ // "required nonblank", checked before anything reaches the network.
1414
+ if (typeof args.query !== 'string' || !args.query.trim()) {
1415
+ return JSON.stringify({ error: '"query" must be a non-empty GraphQL document' })
1416
+ }
1417
+ const data = await gql(key(), args.query, args.variables)
1418
+ // Raw pass-through: the document chooses the shape, so the result is
1419
+ // returned as-is rather than projected through a fixed field set.
1420
+ return JSON.stringify({ data }, null, 2)
1421
+ },
1422
+ }),
1423
+ )
1424
+ }
1425
+
1426
+ // ---------------------------------------------------------------------------
1427
+ // Plugin surface
1428
+ // ---------------------------------------------------------------------------
1429
+
1430
+ /**
1431
+ * Plugin config:
1432
+ * apiKey: Linear personal API key (li_api_...) for the GraphQL tools.
1433
+ * Falls back to the LINEAR_API_KEY environment variable when unset.
1434
+ *
1435
+ * There is no other config. The plugin has no MCP side: it neither hosts an
1436
+ * MCP server nor connects to one, and it reads no MCP-related setting or
1437
+ * environment variable.
1438
+ */
1439
+ // Cordis validates a plugin's `Config` as a Standard Schema (it calls
1440
+ // Config["~standard"].validate(...)). The real API key still comes from the
1441
+ // LINEAR_API_KEY env var (see resolveKey); this schema only needs to accept an
1442
+ // optional `apiKey` string so a patch row may also supply one. `.default('')`
1443
+ // makes a missing field valid; the empty value is ignored by resolveKey, which
1444
+ // prefers config.apiKey only when it is non-empty and otherwise falls back to env.
1445
+ const Config = z.object({
1446
+ apiKey: z.string().default(''),
1447
+ })
1448
+
1449
+ function apply(ctx, config) {
1450
+ registerLinearTools(ctx, config)
1451
+ }
1452
+
1453
+ // Cordis's namespace unwrap keys off { name, inject, apply }; this module
1454
+ // exports exactly that plugin surface and nothing else.
1455
+ export {
1456
+ Config,
1457
+ apply,
1458
+ inject,
1459
+ name,
1460
+ }