@kb1slnlabs/dsh-linear 0.0.0-stage → 0.1.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,1889 @@
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
+ // Two tool surfaces:
8
+ // 1. GraphQL tools (linear_*): talk to the Linear GraphQL API with a personal
9
+ // API token (env LINEAR_API_KEY or config apiKey). Unchanged behaviour.
10
+ // 2. MCP parity tools (linear_mcp_*): dynamic bridge to Linear's HOSTED MCP
11
+ // server over the official Streamable HTTP transport
12
+ // (https://mcp.linear.app/mcp). Tool schemas are DISCOVERED at runtime via
13
+ // `tools/list` and every call is forwarded with `tools/call` using the raw
14
+ // wire name. Credentials are never embedded in tool definitions or names.
15
+ //
16
+ // Peer deps (@deepseek-ai/dsh-tools, @deepseek-ai/cordis, the MCP SDK) resolve
17
+ // from the dsh install's node_modules — this file lives outside any pnpm tree,
18
+ // so a plain import would not find them; the resolvers below pin resolution to
19
+ // the dsh install root by absolute file:// URL.
20
+ //
21
+ // GraphQL tools:
22
+ // linear_issue_list — list issues (filter by team, state, search, limit)
23
+ // linear_issue_get — one issue by id or identifier (e.g. "APT-123")
24
+ // linear_issue_create — create an issue in a team
25
+ // linear_issue_update — update an issue (state, description, assignee, labels, priority)
26
+ // linear_comment_add — add a comment to an issue
27
+ // linear_comment_list — list comments on an issue
28
+ // linear_relation_add — link a related issue to an issue
29
+ // linear_team_list — list teams
30
+ // linear_state_list — list workflow states for a team
31
+ // linear_label_list — list labels for a team
32
+ // linear_project_list — list projects
33
+ // linear_project_create— create a project
34
+ // linear_label_create — create a label for a team
35
+
36
+ import { createHash } from 'node:crypto'
37
+ import { createRequire } from 'node:module'
38
+ import { fileURLToPath, pathToFileURL } from 'node:url'
39
+ import { homedir } from 'node:os'
40
+ import { dirname, join } from 'node:path'
41
+ import { isDeepStrictEqual } from 'node:util'
42
+
43
+ // The plugin source lives outside any pnpm tree, so a bare import('@deepseek-ai/dsh-tools')
44
+ // cannot resolve from here. Every dependency is therefore requested as a STANDARD bare
45
+ // specifier ('@deepseek-ai/dsh-tools', '@deepseek-ai/schemastery', 'zod/v4',
46
+ // '@modelcontextprotocol/sdk/...') and resolved through the packages' own "exports" maps —
47
+ // never through a hardcoded entry filename. Node's own resolver walks up from this file, so
48
+ // when the plugin is installed inside a tree that carries these packages (or one of their
49
+ // peer locations), plain resolution finds them with no anchoring at all. Only when that
50
+ // fails do we fall back to resolving from the dsh install root, derived portably from
51
+ // process.execPath (the npm-global prefix layout on any platform), and only as a last
52
+ // resort is the historical Windows %APPDATA% location probed.
53
+ //
54
+ // We use import() (NOT require()): a synchronous require() of dsh-tools — an ESM module —
55
+ // at module-eval time races the harness's own concurrent import() of the same module and
56
+ // trips Node's ERR_INTERNAL_ASSERTION ("module status 0 ... not yet fully loaded").
57
+ // import() of an absolute file:// URL awaits the same module job and is race-free.
58
+ //
59
+ // Resolution order for every dependency below (identical shape):
60
+ // 1. the DSH_LINEAR_* override env var (explicit entry, any resolvable path/URL)
61
+ // 2. plain Node resolution of the bare specifier from this file
62
+ // 3. the same bare specifier resolved from the dsh install root
63
+
64
+ /** Resolve a bare specifier to a file:// URL, or undefined if nothing provides it. */
65
+ function tryResolveFrom(fromFile, specifier) {
66
+ try {
67
+ return pathToFileURL(createRequire(fromFile).resolve(specifier)).href
68
+ } catch {
69
+ return undefined
70
+ }
71
+ }
72
+
73
+ /** This file's path — the anchor for plain (package.json-driven) resolution. */
74
+ const selfPath = fileURLToPath(import.meta.url)
75
+
76
+ /**
77
+ * Anchor for peer resolution inside a dsh install: the installed @deepseek-ai/dsh package
78
+ * itself, located by walking up from process.execPath to the nearest ancestor that carries
79
+ * .../node_modules/@deepseek-ai/dsh/package.json. execPath-based (not a hardcoded home
80
+ * directory) so it works for any npm/pnpm global prefix on Windows, macOS or Linux.
81
+ */
82
+ function dshInstallAnchor() {
83
+ let dir = dirname((process.execPath || '').replace(/[\\/]+$/, '') || '.')
84
+ for (let depth = 0; depth < 12; depth += 1) {
85
+ const candidate = join(dir, 'node_modules', '@deepseek-ai', 'dsh', 'package.json')
86
+ if (tryResolveFrom(join(dir, 'noop.js'), '@deepseek-ai/dsh/package.json')) return candidate
87
+ const parent = dirname(dir)
88
+ if (parent === dir) break
89
+ dir = parent
90
+ }
91
+ return undefined
92
+ }
93
+
94
+ /**
95
+ * Portable fallback anchors for the dsh install's node_modules, in preference order:
96
+ * 1. npm/pnpm global prefix derived from process.execPath (<prefix>/node_modules)
97
+ * 2. %APPDATA%/npm/node_modules (the historical Windows npm-global location)
98
+ * 3. /usr/local/lib/node_modules and /usr/lib/node_modules (POSIX npm-global defaults)
99
+ */
100
+ function fallbackNodeModulesRoots() {
101
+ const roots = []
102
+ const execPath = (process.execPath || '').replace(/\\/g, '/')
103
+ const marker = '/node_modules/'
104
+ const idx = execPath.lastIndexOf(marker)
105
+ if (idx !== -1) roots.push(execPath.slice(0, idx + '/node_modules'.length))
106
+ if (process.platform === 'win32') {
107
+ roots.push(join(process.env.APPDATA || join(homedir(), 'AppData', 'Roaming'), 'npm', 'node_modules'))
108
+ } else {
109
+ roots.push('/usr/local/lib/node_modules', '/usr/lib/node_modules')
110
+ }
111
+ return roots
112
+ }
113
+
114
+ /**
115
+ * Resolve one bare specifier: plain resolution from this file first, then the dsh install
116
+ * anchor, then each fallback node_modules root. `override` (a DSH_LINEAR_* env var) wins
117
+ * over all of them when set. The final entry is the resolved file:// URL, so import() stays
118
+ * race-free; if nothing resolves, the plain specifier is returned so import() raises Node's
119
+ * own module-not-found error.
120
+ *
121
+ * Peer dependencies are declared as BOTH `dependencies` and `peerDependencies`, because
122
+ * either location may be what Node finds — a copy installed next to this plugin, or the host
123
+ * dsh install's copy. Whichever tree plain resolution lands on must serve every member of a
124
+ * group below: for the MCP SDK that matters beyond version skew, since its zod-backed
125
+ * schemas are identity-sensitive. So if plain resolution provides one member of a group, all
126
+ * members are re-resolved from that same anchor and cached together; if plain resolution
127
+ * provides none of them, the first anchored tree that provides the whole group serves it.
128
+ */
129
+ function resolveEntry(override, specifier) {
130
+ if (override) return override
131
+ const group = CONSISTENCY_GROUPS.find((g) => g.includes(specifier))
132
+ if (!group) return tryResolveFrom(selfPath, specifier) ?? specifier
133
+ if (groupCache.has(specifier)) return groupCache.get(specifier)
134
+ // Anchors in preference order: this plugin's own tree first (that is what a plain bare
135
+ // import would get), then the dsh install, then each npm-global fallback root.
136
+ const anchors = [selfPath, dshInstallAnchor(), ...fallbackNodeModulesRoots().map((root) => join(root, 'noop.js'))].filter(Boolean)
137
+ for (const anchor of anchors) {
138
+ if (resolveGroup(anchor, group)) return groupCache.get(specifier)
139
+ }
140
+ // No tree provided the complete group: fall back to plain resolution per member.
141
+ return tryResolveFrom(selfPath, specifier) ?? specifier
142
+ }
143
+
144
+ /** Specifier groups that must come from one and the same tree (see resolveEntry). */
145
+ const CONSISTENCY_GROUPS = [
146
+ ['@modelcontextprotocol/sdk/client/index.js', '@modelcontextprotocol/sdk/client/streamableHttp.js', '@modelcontextprotocol/sdk/types.js', 'zod/v4'],
147
+ ]
148
+
149
+ /**
150
+ * Resolve every member of `group` from `anchor`; on a complete hit, cache all members and
151
+ * return true. A partial tree pins nothing — incomplete groups are no better than resolving
152
+ * each specifier independently.
153
+ */
154
+ function resolveGroup(anchor, group) {
155
+ const resolved = group.map((member) => tryResolveFrom(anchor, member))
156
+ if (resolved.some((entry) => entry === undefined)) return false
157
+ for (let i = 0; i < group.length; i += 1) if (!groupCache.has(group[i])) groupCache.set(group[i], resolved[i])
158
+ return true
159
+ }
160
+
161
+ const groupCache = new Map()
162
+
163
+ function resolveDshToolsEntry() {
164
+ return resolveEntry(process.env.DSH_LINEAR_TOOLS, '@deepseek-ai/dsh-tools')
165
+ }
166
+
167
+ // Same resolution chain, but for the schemastery schema lib (used to declare the plugin Config).
168
+ function resolveDshSchemasteryEntry() {
169
+ return resolveEntry(process.env.DSH_LINEAR_SCHEMA, '@deepseek-ai/schemastery')
170
+ }
171
+
172
+ // The official MCP SDK — the same package the harness's own
173
+ // @deepseek-ai/dsh-mcp-client bridge depends on, so we reuse the installed copy
174
+ // rather than inventing a transport. Subpaths come from the SDK's own "exports" map
175
+ // instead of a pinned dist path. Resolution is overridable for tests via
176
+ // DSH_LINEAR_MCP_SDK (client/index.js), DSH_LINEAR_MCP_SDK_TYPES (types.js),
177
+ // and DSH_LINEAR_MCP_SDK_TRANSPORT (client/streamableHttp.js).
178
+ // NOTE: in @modelcontextprotocol/sdk 1.x, StreamableHTTPClientTransport lives
179
+ // in client/streamableHttp.js, NOT client/index.js (which exports only Client)
180
+ // — dsh-mcp-client imports it from the same place.
181
+ function resolveMcpSdkClientEntry() {
182
+ return resolveEntry(process.env.DSH_LINEAR_MCP_SDK, '@modelcontextprotocol/sdk/client/index.js')
183
+ }
184
+
185
+ function resolveMcpSdkTransportEntry() {
186
+ return resolveEntry(process.env.DSH_LINEAR_MCP_SDK_TRANSPORT, '@modelcontextprotocol/sdk/client/streamableHttp.js')
187
+ }
188
+
189
+ function resolveMcpSdkTypesEntry() {
190
+ return resolveEntry(process.env.DSH_LINEAR_MCP_SDK_TYPES, '@modelcontextprotocol/sdk/types.js')
191
+ }
192
+
193
+ // The zod the SDK itself resolves to (`import * as z from 'zod/v4'`, hoisted
194
+ // next to it in the dsh install), so RawCallToolResultSchema is built with the
195
+ // same zod major as the SDK's schemas. DSH_LINEAR_MCP_SDK_ZOD overrides it.
196
+ function resolveMcpSdkZodEntry() {
197
+ return resolveEntry(process.env.DSH_LINEAR_MCP_SDK_ZOD, 'zod/v4')
198
+ }
199
+
200
+ const { defineTool, assertSupportedJsonSchema } = await import(resolveDshToolsEntry())
201
+ // schemastery's exports map serves the ESM entry to an import in a tree that provides it;
202
+ // resolved from the dsh install root the same specifier may land on its CJS entry, which
203
+ // Node wraps with both `default` and the named bindings. Take whichever shape is real.
204
+ const schemastery = await import(resolveDshSchemasteryEntry())
205
+ const z = typeof schemastery.default?.object === 'function' ? schemastery.default : schemastery
206
+
207
+ const name = 'linear'
208
+ const inject = ['tools']
209
+
210
+ const API_URL = 'https://api.linear.app/graphql'
211
+
212
+ // ---------------------------------------------------------------------------
213
+ // GraphQL client
214
+ // ---------------------------------------------------------------------------
215
+
216
+ function resolveKey(config) {
217
+ const key = config?.apiKey || process.env.LINEAR_API_KEY
218
+ if (!key) throw new Error('Linear API key not configured: set the LINEAR_API_KEY environment variable (or pass `apiKey` in plugin config).')
219
+ return key
220
+ }
221
+
222
+ async function gql(key, query, variables) {
223
+ const res = await fetch(API_URL, {
224
+ method: 'POST',
225
+ headers: {
226
+ 'Content-Type': 'application/json',
227
+ // Linear API keys are sent as the raw Authorization value — NOT "Bearer <key>".
228
+ Authorization: key,
229
+ },
230
+ body: JSON.stringify({ query, variables }),
231
+ })
232
+ const body = await res.json().catch(() => null)
233
+ if (!res.ok || body?.errors?.length) {
234
+ const msg = body?.errors?.map((e) => e.message).join('; ') || `HTTP ${res.status}`
235
+ throw new Error(`Linear API error: ${msg}`)
236
+ }
237
+ return body.data
238
+ }
239
+
240
+ // ---------------------------------------------------------------------------
241
+ // Field fragments
242
+ // ---------------------------------------------------------------------------
243
+
244
+ const ISSUE_FIELDS = `
245
+ id identifier title description state { id name type }
246
+ assignee { id name }
247
+ priority
248
+ createdAt updatedAt
249
+ labels { nodes { id name } }
250
+ project { id name }
251
+ url
252
+ `
253
+
254
+ const COMMENT_FIELDS = `
255
+ id body createdAt
256
+ `
257
+
258
+ const TEAM_FIELDS = `
259
+ id name key description
260
+ `
261
+
262
+ const STATE_FIELDS = `
263
+ id name type position
264
+ `
265
+
266
+ const LABEL_FIELDS = `
267
+ id name color description
268
+ `
269
+
270
+ const PROJECT_FIELDS = `
271
+ id name description state
272
+ teams { id name key }
273
+ `
274
+
275
+ const CYCLE_FIELDS = `
276
+ id number name startsAt endsAt completedAt
277
+ progress
278
+ team { id name key }
279
+ `
280
+
281
+ const USER_FIELDS = `
282
+ id name displayName email active
283
+ `
284
+
285
+ // ---------------------------------------------------------------------------
286
+ // Helpers
287
+ // ---------------------------------------------------------------------------
288
+
289
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
290
+
291
+ // ---------------------------------------------------------------------------
292
+ // Entity resolution (resolve-then-mutate)
293
+ // ---------------------------------------------------------------------------
294
+ //
295
+ // Linear's GraphQL inputs are FLAT UUID fields — there is no nested
296
+ // `team`/`state` object on IssueCreateInput/IssueUpdateInput, and the singular
297
+ // `Query.team` field accepts ONLY a UUID `id` (no `key`/`name`/`filter`).
298
+ // So every human-facing identifier (team key, state name) must first be
299
+ // resolved to a UUID through the PLURAL `teams` / `workflowStates` query
300
+ // fields, which DO accept filters:
301
+ //
302
+ // teams(filter: { key: { eq: "APT" } }) { nodes { id key name } }
303
+ // workflowStates(filter: { team: { id: { eq: <teamId> } },
304
+ // name: { eq: "Backlog" } }) { nodes { id name } }
305
+ //
306
+ // The resolvers below throw a clear error when the identifier does not resolve
307
+ // to exactly one entity, so a bad key never reaches a mutation as a malformed
308
+ // nested object.
309
+
310
+ // Resolve a team key or name to its UUID via the PLURAL `teams` field (which
311
+ // accepts a TeamFilter). Pass a UUID through unchanged.
312
+ async function resolveTeamId(key, ref) {
313
+ if (UUID_RE.test(ref)) return ref
314
+ const isEmail = ref.includes('@')
315
+ const data = await gql(key, `
316
+ query($v: String!) {
317
+ teams(first: 10, filter: { ${isEmail ? 'name' : 'key'}: { eq: $v } }) {
318
+ nodes { id key name }
319
+ }
320
+ }
321
+ `, { v: ref })
322
+ const nodes = data.teams?.nodes ?? []
323
+ if (nodes.length !== 1) {
324
+ throw new Error(`Team '${ref}' not found (resolved ${nodes.length} teams via the teams filter)`)
325
+ }
326
+ return nodes[0].id
327
+ }
328
+
329
+ // Resolve a workflow-state name to its UUID within a team (UUID required).
330
+ // Uses the top-level `workflowStates` query field, filtered by team id + name.
331
+ async function resolveStateId(key, teamId, stateName) {
332
+ const data = await gql(key, `
333
+ query($teamId: String!, $name: String!) {
334
+ workflowStates(filter: { team: { id: { eq: $teamId } }, name: { eq: $name } }) {
335
+ nodes { id name }
336
+ }
337
+ }
338
+ `, { teamId, name: stateName })
339
+ const nodes = data.workflowStates?.nodes ?? []
340
+ if (nodes.length !== 1) {
341
+ throw new Error(`State '${stateName}' not found for team ${teamId} (resolved ${nodes.length} states)`)
342
+ }
343
+ return nodes[0].id
344
+ }
345
+
346
+ // Resolve a human identifier ("APT-804") or a UUID to a Linear issue UUID.
347
+ // IssueFilter has no `identifier` field, so we split the identifier into
348
+ // team key + number and look the issue up by those, then return its id.
349
+ async function findIssueId(key, id) {
350
+ if (UUID_RE.test(id)) return id
351
+ const m = id.match(/^(.+?)-(\d+)$/)
352
+ if (!m) return null
353
+ const [, teamKey, num] = m
354
+ const data = await gql(key, `
355
+ query($team: String!, $num: Float!) {
356
+ issues(filter: { team: { key: { eq: $team } }, number: { eq: $num } }) {
357
+ nodes { id }
358
+ }
359
+ }
360
+ `, { team: teamKey, num: Number(num) })
361
+ return data.issues.nodes[0]?.id ?? null
362
+ }
363
+
364
+ // ---------------------------------------------------------------------------
365
+ // GraphQL tools
366
+ // ---------------------------------------------------------------------------
367
+
368
+ function registerLinearTools(ctx, config) {
369
+ const key = () => resolveKey(config)
370
+
371
+ // Wrap only Linear tools so any failure (a resolution error like
372
+ // "Team 'X' not found", a Linear API error, or a network error) surfaces as
373
+ // a clean JSON { error } payload instead of a thrown stack trace. The
374
+ // resolution helpers throw deliberately; this guard turns that into the
375
+ // clear, model-readable error the bug report requires.
376
+ const originalRegister = ctx.tools.register.bind(ctx.tools)
377
+ const register = (def) =>
378
+ originalRegister({
379
+ ...def,
380
+ execute: async (args, opts) => {
381
+ try {
382
+ return await def.execute(args, opts)
383
+ } catch (err) {
384
+ return JSON.stringify({ error: err?.message || String(err) })
385
+ }
386
+ },
387
+ })
388
+
389
+ // -- Issues ---------------------------------------------------------------
390
+
391
+ register(
392
+ defineTool({
393
+ name: 'linear_issue_list',
394
+ description:
395
+ '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).',
396
+ parameters: {
397
+ team: { type: 'string', description: 'Team key to filter by, e.g. "APT"' },
398
+ state: { type: 'string', description: 'State name to filter by, e.g. "Done", "In Progress"' },
399
+ search: { type: 'string', description: 'Free-text search across title/description' },
400
+ limit: { type: 'number', description: 'Max issues to return (default 20, max 50)' },
401
+ },
402
+ output: {
403
+ schema: { type: 'string' },
404
+ render: (_a, v) => [{ type: 'text', text: v }],
405
+ },
406
+ async execute(args) {
407
+ const limit = Math.min(Math.max(1, Math.floor(args.limit ?? 20)), 50)
408
+ const filterParts = []
409
+ const vars = {}
410
+ if (args.team) { filterParts.push(`team: { key: { eq: $team } }`); vars.team = args.team }
411
+ if (args.state) { filterParts.push(`state: { name: { eq: $state } }`); vars.state = args.state }
412
+ if (args.search) { filterParts.push(`OR: { title: { containsInsensitive: $search }, description: { containsInsensitive: $search } }`); vars.search = args.search }
413
+ const filter = filterParts.length ? `filter: { ${filterParts.join(', ')} }` : ''
414
+ const usedVars = Object.keys(vars)
415
+ const varDecl = usedVars.length ? `query(${usedVars.map((v) => `$${v}: String`).join(', ')})` : 'query'
416
+ const data = await gql(key(), `
417
+ ${varDecl} {
418
+ issues(first: ${limit}, ${filter}) {
419
+ nodes { ${ISSUE_FIELDS} }
420
+ }
421
+ }
422
+ `, vars)
423
+ const issues = data.issues.nodes.map((i) => ({
424
+ id: i.id,
425
+ identifier: i.identifier,
426
+ title: i.title,
427
+ state: i.state?.name,
428
+ assignee: i.assignee?.name,
429
+ priority: i.priority,
430
+ project: i.project?.name,
431
+ labels: i.labels.nodes.map((l) => l.name),
432
+ url: i.url,
433
+ }))
434
+ return JSON.stringify({ count: issues.length, issues }, null, 2)
435
+ },
436
+ }),
437
+ )
438
+
439
+ register(
440
+ defineTool({
441
+ name: 'linear_issue_get',
442
+ description:
443
+ '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.',
444
+ parameters: {
445
+ id: { type: 'string', required: true, description: 'Issue id (UUID) or identifier like "APT-123"' },
446
+ },
447
+ output: {
448
+ schema: { type: 'string' },
449
+ render: (_a, v) => [{ type: 'text', text: v }],
450
+ },
451
+ async execute(args) {
452
+ const issueId = await findIssueId(key(), args.id)
453
+ if (!issueId) return JSON.stringify({ error: `Issue not found: ${args.id}` })
454
+ const data = await gql(key(), `
455
+ query($id: String!) {
456
+ issue: issue(id: $id) {
457
+ ${ISSUE_FIELDS}
458
+ relations { nodes { type relatedIssue { ${ISSUE_FIELDS} } } }
459
+ }
460
+ }
461
+ `, { id: issueId })
462
+ const i = data.issue
463
+ if (!i) return JSON.stringify({ error: `Issue not found: ${args.id}` })
464
+ return JSON.stringify({
465
+ id: i.id,
466
+ identifier: i.identifier,
467
+ title: i.title,
468
+ description: i.description,
469
+ state: i.state?.name,
470
+ assignee: i.assignee?.name,
471
+ priority: i.priority,
472
+ project: i.project?.name,
473
+ labels: i.labels.nodes.map((l) => l.name),
474
+ 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 }),
475
+ url: i.url,
476
+ createdAt: i.createdAt,
477
+ updatedAt: i.updatedAt,
478
+ }, null, 2)
479
+ },
480
+ }),
481
+ )
482
+
483
+ register(
484
+ defineTool({
485
+ name: 'linear_issue_create',
486
+ description:
487
+ '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).',
488
+ parameters: {
489
+ team: { type: 'string', required: true, description: 'Team key, e.g. "APT"' },
490
+ title: { type: 'string', required: true, description: 'Issue title' },
491
+ description: { type: 'string', description: 'Issue description (markdown)' },
492
+ state: { type: 'string', description: 'Initial state name, e.g. "Todo", "In Progress"' },
493
+ assignee: { type: 'string', description: 'Assignee user id (UUID)' },
494
+ labels: { type: 'array', items: { type: 'string' }, description: 'Label ids (UUIDs) to attach' },
495
+ priority: { type: 'number', description: 'Priority 0 (none) to 4 (urgent)' },
496
+ },
497
+ output: {
498
+ schema: { type: 'string' },
499
+ render: (_a, v) => [{ type: 'text', text: v }],
500
+ },
501
+ async execute(args) {
502
+ // Resolve human identifiers to UUIDs first (IssueCreateInput is flat:
503
+ // teamId is required, stateId/assigneeId/labelIds are UUIDs).
504
+ const teamId = await resolveTeamId(key(), args.team)
505
+ const input = { teamId, title: args.title }
506
+ if (args.description) input.description = args.description
507
+ if (args.state) input.stateId = await resolveStateId(key(), teamId, args.state)
508
+ if (args.assignee) input.assigneeId = args.assignee
509
+ if (args.labels?.length) input.labelIds = args.labels
510
+ if (args.priority !== undefined) input.priority = args.priority
511
+ const data = await gql(key(), `
512
+ mutation($input: IssueCreateInput!) {
513
+ issueCreate(input: $input) {
514
+ success issue { ${ISSUE_FIELDS} }
515
+ }
516
+ }
517
+ `, { input })
518
+ if (!data.issueCreate.success) return JSON.stringify({ error: 'issueCreate failed' })
519
+ const i = data.issueCreate.issue
520
+ return JSON.stringify({
521
+ id: i.id,
522
+ identifier: i.identifier,
523
+ title: i.title,
524
+ state: i.state?.name,
525
+ url: i.url,
526
+ }, null, 2)
527
+ },
528
+ }),
529
+ )
530
+
531
+ register(
532
+ defineTool({
533
+ name: 'linear_issue_update',
534
+ description:
535
+ 'Update an existing Linear issue by id (UUID) or identifier. Any of: state name, description, assignee id, label ids, priority (0-4), title.',
536
+ parameters: {
537
+ id: { type: 'string', required: true, description: 'Issue id (UUID) or identifier' },
538
+ title: { type: 'string', description: 'New title' },
539
+ description: { type: 'string', description: 'New description (markdown)' },
540
+ state: { type: 'string', description: 'New state name, e.g. "Done", "In Progress"' },
541
+ assignee: { type: 'string', description: 'New assignee user id (UUID); empty string to unassign' },
542
+ labels: { type: 'array', items: { type: 'string' }, description: 'Label ids to set (replaces existing)' },
543
+ priority: { type: 'number', description: 'Priority 0-4' },
544
+ },
545
+ output: {
546
+ schema: { type: 'string' },
547
+ render: (_a, v) => [{ type: 'text', text: v }],
548
+ },
549
+ async execute(args) {
550
+ const issueId = await findIssueId(key(), args.id)
551
+ if (!issueId) return JSON.stringify({ error: `Issue not found: ${args.id}` })
552
+
553
+ // IssueUpdateInput is flat (stateId/assigneeId/labelIds are UUIDs).
554
+ // A state change needs the issue's team to resolve the state name.
555
+ const input = { id: issueId }
556
+ if (args.title !== undefined) input.title = args.title
557
+ if (args.description !== undefined) input.description = args.description
558
+ if (args.assignee !== undefined) input.assigneeId = args.assignee || null
559
+ if (args.labels !== undefined) input.labelIds = args.labels
560
+ if (args.priority !== undefined) input.priority = args.priority
561
+ if (args.state !== undefined) {
562
+ const issueData = await gql(key(), `
563
+ query($id: String!) {
564
+ issue: issue(id: $id) { id team { id } }
565
+ }
566
+ `, { id: issueId })
567
+ const teamId = issueData.issue?.team?.id
568
+ if (!teamId) return JSON.stringify({ error: `Could not resolve team for issue ${args.id}` })
569
+ input.stateId = await resolveStateId(key(), teamId, args.state)
570
+ }
571
+
572
+ const data = await gql(key(), `
573
+ mutation($input: IssueUpdateInput!) {
574
+ issueUpdate(input: $input) {
575
+ success issue { ${ISSUE_FIELDS} }
576
+ }
577
+ }
578
+ `, { input })
579
+ if (!data.issueUpdate.success) return JSON.stringify({ error: 'issueUpdate failed' })
580
+ const i = data.issueUpdate.issue
581
+ return JSON.stringify({
582
+ id: i.id,
583
+ identifier: i.identifier,
584
+ title: i.title,
585
+ state: i.state?.name,
586
+ assignee: i.assignee?.name,
587
+ url: i.url,
588
+ }, null, 2)
589
+ },
590
+ }),
591
+ )
592
+
593
+ // -- Comments ---------------------------------------------------------------
594
+
595
+ register(
596
+ defineTool({
597
+ name: 'linear_comment_add',
598
+ description: 'Add a comment to a Linear issue (by id or identifier).',
599
+ parameters: {
600
+ issue: { type: 'string', required: true, description: 'Issue id or identifier' },
601
+ body: { type: 'string', required: true, description: 'Comment body (markdown)' },
602
+ },
603
+ output: {
604
+ schema: { type: 'string' },
605
+ render: (_a, v) => [{ type: 'text', text: v }],
606
+ },
607
+ async execute(args) {
608
+ const issueId = await findIssueId(key(), args.issue)
609
+ if (!issueId) return JSON.stringify({ error: `Issue not found: ${args.issue}` })
610
+ const data = await gql(key(), `
611
+ mutation($input: CommentCreateInput!) {
612
+ commentCreate(input: $input) {
613
+ success comment { ${COMMENT_FIELDS} }
614
+ }
615
+ }
616
+ `, { input: { issueId, body: args.body } })
617
+ if (!data.commentCreate.success) return JSON.stringify({ error: 'commentCreate failed' })
618
+ return JSON.stringify({ id: data.commentCreate.comment.id, body: data.commentCreate.comment.body }, null, 2)
619
+ },
620
+ }),
621
+ )
622
+
623
+ register(
624
+ defineTool({
625
+ name: 'linear_comment_list',
626
+ description: 'List comments on a Linear issue (by id or identifier), newest first.',
627
+ parameters: {
628
+ issue: { type: 'string', required: true, description: 'Issue id or identifier' },
629
+ limit: { type: 'number', description: 'Max comments (default 20, max 50)' },
630
+ },
631
+ output: {
632
+ schema: { type: 'string' },
633
+ render: (_a, v) => [{ type: 'text', text: v }],
634
+ },
635
+ async execute(args) {
636
+ const limit = Math.min(Math.max(1, Math.floor(args.limit ?? 20)), 50)
637
+ const issueId = await findIssueId(key(), args.issue)
638
+ if (!issueId) return JSON.stringify({ error: `Issue not found: ${args.issue}` })
639
+ const data = await gql(key(), `
640
+ query($id: String!) {
641
+ issue: issue(id: $id) {
642
+ id
643
+ comments(first: ${limit}) {
644
+ nodes { ${COMMENT_FIELDS} }
645
+ }
646
+ }
647
+ }
648
+ `, { id: issueId })
649
+ if (!data.issue) return JSON.stringify({ error: `Issue not found: ${args.issue}` })
650
+ return JSON.stringify({
651
+ issue: args.issue,
652
+ count: data.issue.comments.nodes.length,
653
+ comments: data.issue.comments.nodes,
654
+ }, null, 2)
655
+ },
656
+ }),
657
+ )
658
+
659
+ // -- Relations --------------------------------------------------------------
660
+
661
+ register(
662
+ defineTool({
663
+ name: 'linear_relation_add',
664
+ description:
665
+ 'Link a related issue to an issue. Both given by id or identifier. `type` is the relation type (default "related").',
666
+ parameters: {
667
+ issue: { type: 'string', required: true, description: 'Source issue id or identifier' },
668
+ related: { type: 'string', required: true, description: 'Related issue id or identifier' },
669
+ type: { type: 'string', description: 'Relation type: related, blockedBy, blocks, parent, child (default "related")' },
670
+ },
671
+ output: {
672
+ schema: { type: 'string' },
673
+ render: (_a, v) => [{ type: 'text', text: v }],
674
+ },
675
+ async execute(args) {
676
+ const type = args.type ?? 'related'
677
+ const VALID_RELATION_TYPES = ['related', 'blockedBy', 'blocks', 'parent', 'child']
678
+ if (!VALID_RELATION_TYPES.includes(type)) {
679
+ return JSON.stringify({ error: `invalid relation type: ${type}, expected one of: ${VALID_RELATION_TYPES.join(', ')}` })
680
+ }
681
+ const [issueId, relatedId] = await Promise.all([
682
+ findIssueId(key(), args.issue),
683
+ findIssueId(key(), args.related),
684
+ ])
685
+ if (!issueId) return JSON.stringify({ error: `Issue not found: ${args.issue}` })
686
+ if (!relatedId) return JSON.stringify({ error: `Issue not found: ${args.related}` })
687
+ const data = await gql(key(), `
688
+ mutation($input: IssueRelationCreateInput!) {
689
+ issueRelationCreate(input: $input) {
690
+ success relation { id }
691
+ }
692
+ }
693
+ `, { input: { issueId, relatedIssueId: relatedId, type } })
694
+ if (!data.issueRelationCreate.success) return JSON.stringify({ error: 'issueRelationCreate failed' })
695
+ return JSON.stringify({ ok: true, relationId: data.issueRelationCreate.relation.id }, null, 2)
696
+ },
697
+ }),
698
+ )
699
+
700
+ // -- Teams / states / labels / projects -------------------------------------
701
+
702
+ register(
703
+ defineTool({
704
+ name: 'linear_team_list',
705
+ description: 'List all Linear teams (key, name, description).',
706
+ parameters: {},
707
+ output: {
708
+ schema: { type: 'string' },
709
+ render: (_a, v) => [{ type: 'text', text: v }],
710
+ },
711
+ async execute() {
712
+ const data = await gql(key(), `
713
+ {
714
+ teams(first: 100) {
715
+ nodes { ${TEAM_FIELDS} }
716
+ }
717
+ }
718
+ `)
719
+ return JSON.stringify({ count: data.teams.nodes.length, teams: data.teams.nodes }, null, 2)
720
+ },
721
+ }),
722
+ )
723
+
724
+ register(
725
+ defineTool({
726
+ name: 'linear_state_list',
727
+ description: 'List workflow states for a team (e.g. Todo, In Progress, Done).',
728
+ parameters: {
729
+ team: { type: 'string', required: true, description: 'Team key, e.g. "APT"' },
730
+ },
731
+ output: {
732
+ schema: { type: 'string' },
733
+ render: (_a, v) => [{ type: 'text', text: v }],
734
+ },
735
+ async execute(args) {
736
+ // Resolve the team to a UUID via the PLURAL `teams` field (the singular
737
+ // `team` field takes only a UUID id), then list that team's states via
738
+ // the top-level `workflowStates` field filtered by team id.
739
+ const teamId = await resolveTeamId(key(), args.team)
740
+ const data = await gql(key(), `
741
+ query($teamId: String!) {
742
+ workflowStates(filter: { team: { id: { eq: $teamId } } }) {
743
+ nodes { ${STATE_FIELDS} }
744
+ }
745
+ }
746
+ `, { teamId })
747
+ return JSON.stringify({ team: args.team, states: data.workflowStates.nodes }, null, 2)
748
+ },
749
+ }),
750
+ )
751
+
752
+ register(
753
+ defineTool({
754
+ name: 'linear_label_list',
755
+ description: 'List labels for a team.',
756
+ parameters: {
757
+ team: { type: 'string', required: true, description: 'Team key, e.g. "APT"' },
758
+ },
759
+ output: {
760
+ schema: { type: 'string' },
761
+ render: (_a, v) => [{ type: 'text', text: v }],
762
+ },
763
+ async execute(args) {
764
+ const teamId = await resolveTeamId(key(), args.team)
765
+ const data = await gql(key(), `
766
+ query($teamId: String!) {
767
+ issueLabels(filter: { team: { id: { eq: $teamId } } }) {
768
+ nodes { ${LABEL_FIELDS} }
769
+ }
770
+ }
771
+ `, { teamId })
772
+ return JSON.stringify({ team: args.team, labels: data.issueLabels.nodes }, null, 2)
773
+ },
774
+ }),
775
+ )
776
+
777
+ register(
778
+ defineTool({
779
+ name: 'linear_project_list',
780
+ description: 'List Linear projects (name, description, state, teams).',
781
+ parameters: {
782
+ limit: { type: 'number', description: 'Max projects (default 20, max 50)' },
783
+ },
784
+ output: {
785
+ schema: { type: 'string' },
786
+ render: (_a, v) => [{ type: 'text', text: v }],
787
+ },
788
+ async execute(args) {
789
+ const limit = Math.min(Math.max(1, Math.floor(args.limit ?? 20)), 50)
790
+ const data = await gql(key(), `
791
+ {
792
+ projects(first: ${limit}) {
793
+ nodes { ${PROJECT_FIELDS} }
794
+ }
795
+ }
796
+ `)
797
+ return JSON.stringify({ count: data.projects.nodes.length, projects: data.projects.nodes }, null, 2)
798
+ },
799
+ }),
800
+ )
801
+
802
+ register(
803
+ defineTool({
804
+ name: 'linear_project_create',
805
+ description: 'Create a Linear project. `name` required; optional description and team keys to include.',
806
+ parameters: {
807
+ name: { type: 'string', required: true, description: 'Project name' },
808
+ description: { type: 'string', description: 'Project description' },
809
+ teams: { type: 'array', items: { type: 'string' }, description: 'Team keys to include in the project' },
810
+ },
811
+ output: {
812
+ schema: { type: 'string' },
813
+ render: (_a, v) => [{ type: 'text', text: v }],
814
+ },
815
+ async execute(args) {
816
+ const input = { name: args.name }
817
+ if (args.description) input.description = args.description
818
+ if (args.teams?.length) {
819
+ // ProjectCreateInput takes flat team UUIDs (teamIds), not nested keys.
820
+ input.teamIds = await Promise.all(args.teams.map((t) => resolveTeamId(key(), t)))
821
+ }
822
+ const data = await gql(key(), `
823
+ mutation($input: ProjectCreateInput!) {
824
+ projectCreate(input: $input) {
825
+ success project { ${PROJECT_FIELDS} }
826
+ }
827
+ }
828
+ `, { input })
829
+ if (!data.projectCreate.success) return JSON.stringify({ error: 'projectCreate failed' })
830
+ return JSON.stringify(data.projectCreate.project, null, 2)
831
+ },
832
+ }),
833
+ )
834
+
835
+ register(
836
+ defineTool({
837
+ name: 'linear_label_create',
838
+ description: 'Create a label for a team.',
839
+ parameters: {
840
+ team: { type: 'string', required: true, description: 'Team key, e.g. "APT"' },
841
+ name: { type: 'string', required: true, description: 'Label name' },
842
+ color: { type: 'string', description: 'Hex color, e.g. "#ff0000"' },
843
+ description: { type: 'string', description: 'Label description' },
844
+ },
845
+ output: {
846
+ schema: { type: 'string' },
847
+ render: (_a, v) => [{ type: 'text', text: v }],
848
+ },
849
+ async execute(args) {
850
+ // LabelCreateInput takes a flat team UUID (teamId), not a nested key.
851
+ const teamId = await resolveTeamId(key(), args.team)
852
+ const data = await gql(key(), `
853
+ mutation($input: IssueLabelCreateInput!) {
854
+ issueLabelCreate(input: $input) {
855
+ success label { ${LABEL_FIELDS} }
856
+ }
857
+ }
858
+ `, {
859
+ input: {
860
+ teamId,
861
+ name: args.name,
862
+ ...(args.color ? { color: args.color } : {}),
863
+ ...(args.description ? { description: args.description } : {}),
864
+ },
865
+ })
866
+ if (!data.issueLabelCreate.success) return JSON.stringify({ error: 'issueLabelCreate failed' })
867
+ return JSON.stringify(data.issueLabelCreate.label, null, 2)
868
+ },
869
+ }),
870
+ )
871
+
872
+ // -- Cycles -----------------------------------------------------------------
873
+
874
+ register(
875
+ defineTool({
876
+ name: 'linear_cycle_list',
877
+ description: 'List cycles for a team. Returns up to `limit` most recent cycles (default 20, max 50).',
878
+ parameters: {
879
+ team: { type: 'string', required: true, description: 'Team key, e.g. "APT"' },
880
+ limit: { type: 'number', description: 'Max cycles (default 20, max 50)' },
881
+ },
882
+ output: {
883
+ schema: { type: 'string' },
884
+ render: (_a, v) => [{ type: 'text', text: v }],
885
+ },
886
+ async execute(args) {
887
+ const limit = Math.min(Math.max(1, Math.floor(args.limit ?? 20)), 50)
888
+ const teamId = await resolveTeamId(key(), args.team)
889
+ const data = await gql(key(), `
890
+ query($teamId: String!) {
891
+ cycles(first: ${limit}, filter: { team: { id: { eq: $teamId } } }) {
892
+ nodes { ${CYCLE_FIELDS} }
893
+ }
894
+ }
895
+ `, { teamId })
896
+ const cycles = data.cycles.nodes.map((c) => ({
897
+ id: c.id,
898
+ number: c.number,
899
+ name: c.name,
900
+ startsAt: c.startsAt,
901
+ endsAt: c.endsAt,
902
+ completedAt: c.completedAt,
903
+ }))
904
+ return JSON.stringify({ team: args.team, count: cycles.length, cycles }, null, 2)
905
+ },
906
+ }),
907
+ )
908
+
909
+ register(
910
+ defineTool({
911
+ name: 'linear_cycle_get',
912
+ description: 'Get a single cycle by its id (UUID). Returns full details including team, progress, and dates.',
913
+ parameters: {
914
+ id: { type: 'string', required: true, description: 'Cycle id (UUID)' },
915
+ },
916
+ output: {
917
+ schema: { type: 'string' },
918
+ render: (_a, v) => [{ type: 'text', text: v }],
919
+ },
920
+ async execute(args) {
921
+ const data = await gql(key(), `
922
+ query($id: String!) {
923
+ cycle: cycle(id: $id) {
924
+ ${CYCLE_FIELDS}
925
+ }
926
+ }
927
+ `, { id: args.id })
928
+ if (!data.cycle) return JSON.stringify({ error: `Cycle not found: ${args.id}` })
929
+ return JSON.stringify(data.cycle, null, 2)
930
+ },
931
+ }),
932
+ )
933
+
934
+ // -- Users ------------------------------------------------------------------
935
+
936
+ register(
937
+ defineTool({
938
+ name: 'linear_user_list',
939
+ description: 'List users in the workspace. Returns up to `limit` users (default 20, max 50).',
940
+ parameters: {
941
+ limit: { type: 'number', description: 'Max users (default 20, max 50)' },
942
+ },
943
+ output: {
944
+ schema: { type: 'string' },
945
+ render: (_a, v) => [{ type: 'text', text: v }],
946
+ },
947
+ async execute(args) {
948
+ const limit = Math.min(Math.max(1, Math.floor(args.limit ?? 20)), 50)
949
+ const data = await gql(key(), `
950
+ {
951
+ users(first: ${limit}) {
952
+ nodes { ${USER_FIELDS} }
953
+ }
954
+ }
955
+ `)
956
+ return JSON.stringify({ count: data.users.nodes.length, users: data.users.nodes }, null, 2)
957
+ },
958
+ }),
959
+ )
960
+
961
+ register(
962
+ defineTool({
963
+ name: 'linear_user_get',
964
+ description: 'Get a single user by id (UUID) or email. Returns full user details.',
965
+ parameters: {
966
+ id: { type: 'string', required: true, description: 'User id (UUID) or email address' },
967
+ },
968
+ output: {
969
+ schema: { type: 'string' },
970
+ render: (_a, v) => [{ type: 'text', text: v }],
971
+ },
972
+ async execute(args) {
973
+ let user
974
+ if (UUID_RE.test(args.id)) {
975
+ const data = await gql(key(), `
976
+ query($id: String!) {
977
+ user: user(id: $id) { ${USER_FIELDS} }
978
+ }
979
+ `, { id: args.id })
980
+ user = data.user
981
+ } else {
982
+ const data = await gql(key(), `
983
+ query($email: String!) {
984
+ users(filter: { email: { eq: $email } }) {
985
+ nodes { ${USER_FIELDS} }
986
+ }
987
+ }
988
+ `, { email: args.id })
989
+ user = data.users.nodes[0] ?? null
990
+ }
991
+ if (!user) return JSON.stringify({ error: `User not found: ${args.id}` })
992
+ return JSON.stringify(user, null, 2)
993
+ },
994
+ }),
995
+ )
996
+
997
+ // -- Single-item getters (project / label / team) ---------------------------
998
+
999
+ register(
1000
+ defineTool({
1001
+ name: 'linear_project_get',
1002
+ description: 'Get a single project by its id (UUID). Returns full project details including teams.',
1003
+ parameters: {
1004
+ id: { type: 'string', required: true, description: 'Project id (UUID)' },
1005
+ },
1006
+ output: {
1007
+ schema: { type: 'string' },
1008
+ render: (_a, v) => [{ type: 'text', text: v }],
1009
+ },
1010
+ async execute(args) {
1011
+ const data = await gql(key(), `
1012
+ query($id: String!) {
1013
+ project: project(id: $id) {
1014
+ ${PROJECT_FIELDS}
1015
+ }
1016
+ }
1017
+ `, { id: args.id })
1018
+ if (!data.project) return JSON.stringify({ error: `Project not found: ${args.id}` })
1019
+ return JSON.stringify(data.project, null, 2)
1020
+ },
1021
+ }),
1022
+ )
1023
+
1024
+ register(
1025
+ defineTool({
1026
+ name: 'linear_label_get',
1027
+ description: 'Get a single label by its id (UUID). Returns full label details.',
1028
+ parameters: {
1029
+ id: { type: 'string', required: true, description: 'Label id (UUID)' },
1030
+ },
1031
+ output: {
1032
+ schema: { type: 'string' },
1033
+ render: (_a, v) => [{ type: 'text', text: v }],
1034
+ },
1035
+ async execute(args) {
1036
+ const data = await gql(key(), `
1037
+ query($id: String!) {
1038
+ label: issueLabel(id: $id) {
1039
+ ${LABEL_FIELDS}
1040
+ }
1041
+ }
1042
+ `, { id: args.id })
1043
+ if (!data.label) return JSON.stringify({ error: `Label not found: ${args.id}` })
1044
+ return JSON.stringify(data.label, null, 2)
1045
+ },
1046
+ }),
1047
+ )
1048
+
1049
+ register(
1050
+ defineTool({
1051
+ name: 'linear_team_get',
1052
+ description: 'Get a single team by its id (UUID) or key (e.g. "APT"). Returns full team details.',
1053
+ parameters: {
1054
+ id: { type: 'string', required: true, description: 'Team id (UUID) or key like "APT"' },
1055
+ },
1056
+ output: {
1057
+ schema: { type: 'string' },
1058
+ render: (_a, v) => [{ type: 'text', text: v }],
1059
+ },
1060
+ async execute(args) {
1061
+ let team
1062
+ if (UUID_RE.test(args.id)) {
1063
+ const data = await gql(key(), `
1064
+ query($id: String!) {
1065
+ team: team(id: $id) { ${TEAM_FIELDS} }
1066
+ }
1067
+ `, { id: args.id })
1068
+ team = data.team
1069
+ } else {
1070
+ // The singular `team` field takes only a UUID id; a key/name must be
1071
+ // resolved through the PLURAL `teams` field (which accepts a filter).
1072
+ const data = await gql(key(), `
1073
+ query($key: String!) {
1074
+ teams(first: 10, filter: { key: { eq: $key } }) {
1075
+ nodes { ${TEAM_FIELDS} }
1076
+ }
1077
+ }
1078
+ `, { key: args.id })
1079
+ const nodes = data.teams?.nodes ?? []
1080
+ if (nodes.length !== 1) return JSON.stringify({ error: `Team not found: ${args.id}` })
1081
+ team = nodes[0]
1082
+ }
1083
+ if (!team) return JSON.stringify({ error: `Team not found: ${args.id}` })
1084
+ return JSON.stringify(team, null, 2)
1085
+ },
1086
+ }),
1087
+ )
1088
+ }
1089
+
1090
+ // ---------------------------------------------------------------------------
1091
+ // MCP bridge — parity with Linear's hosted MCP server
1092
+ // ---------------------------------------------------------------------------
1093
+ //
1094
+ // Linear's hosted MCP server (https://mcp.linear.app/mcp) speaks the official
1095
+ // Model Context Protocol over Streamable HTTP. This bridge:
1096
+ // - connects with the installed @modelcontextprotocol/sdk Client +
1097
+ // StreamableHTTPClientTransport (the same transport dsh-mcp-client uses),
1098
+ // - authenticates with `Authorization: Bearer <token>` — Linear's hosted MCP
1099
+ // endpoint expects the Bearer scheme, unlike the raw-value GraphQL header;
1100
+ // the token is only ever placed in request headers, never in a tool name,
1101
+ // description, schema, or log line,
1102
+ // - DISCOVERS the server's tools at connect time with `tools/list` (paginated)
1103
+ // and registers one harness tool per discovered MCP tool, so no Linear MCP
1104
+ // API is hand-invented here,
1105
+ // - forwards every call with `tools/call` using the RAW wire name (never the
1106
+ // public name), mirroring dsh-mcp-client's naming contract:
1107
+ // public name = `linear_mcp_<rawName>` when it fits the DeepSeek
1108
+ // function-name contract (≤64 chars, [A-Za-z0-9_-]), otherwise a lossy
1109
+ // normalization plus a 12-hex SHA-256 hash of the identity so distinct
1110
+ // MCP identities never collapse onto one public name. Names are a pure
1111
+ // function of the discovered raw name → stable across reconnects,
1112
+ // - on `notifications/tools/list_changed` re-discovers and atomically swaps
1113
+ // the registration generation (fetch fully first; only then dispose the old
1114
+ // generation and register the new one),
1115
+ // - on connection loss rebuilds the whole generation with bounded exponential
1116
+ // backoff, and unregisters when reconnect is disabled or exhausted.
1117
+ //
1118
+ // The bridge is fail-soft: without an MCP token (LINEAR_MCP_TOKEN / config),
1119
+ // with no loadable MCP SDK, or in a minimal ctx that lacks `ctx.effect`, the
1120
+ // GraphQL suite still registers and the bridge simply stays off.
1121
+
1122
+ const DEFAULT_MCP_URL = 'https://mcp.linear.app/mcp'
1123
+ const MCP_NAME_PREFIX = 'linear_mcp_'
1124
+ const MAX_PUBLIC_NAME_LENGTH = 64
1125
+ const INVALID_NAME_CHARS = /[^A-Za-z0-9_-]/g
1126
+ const HASH_LENGTH = 12
1127
+ /** Defaults shared by the Reconnect schema and resolveReconnectPolicy. */
1128
+ const RECONNECT_DEFAULTS = Object.freeze({
1129
+ enabled: true,
1130
+ initialDelayMs: 500,
1131
+ maxDelayMs: 30000,
1132
+ maxAttempts: 10,
1133
+ })
1134
+ /** Default timeout for individual MCP tool calls (ms). */
1135
+ const DEFAULT_MCP_TOOL_CALL_TIMEOUT_MS = 60000
1136
+
1137
+ // Resolve the MCP SDK lazily but once, so a missing/unreachable SDK never
1138
+ // breaks the GraphQL tools that share this module.
1139
+ // The SDK (1.30.0, same copy dsh-mcp-client uses) has no RawCallToolResultSchema
1140
+ // export; mirror dsh-mcp-client's raw record schema — `z.record(z.string(),
1141
+ // z.unknown())` from the same zod the SDK resolves to — so the bridge owns
1142
+ // result validation instead of the SDK pre-validating a different contract.
1143
+ // DSH_LINEAR_MCP_SDK_ZOD overrides the zod URL for offline tests.
1144
+ let mcpSdkPromise
1145
+ function loadMcpSdk() {
1146
+ mcpSdkPromise ??= (async () => {
1147
+ const sdk = await import(resolveMcpSdkClientEntry())
1148
+ const transport = await import(resolveMcpSdkTransportEntry())
1149
+ const types = await import(resolveMcpSdkTypesEntry())
1150
+ const zod = await import(resolveMcpSdkZodEntry())
1151
+ return {
1152
+ Client: sdk.Client,
1153
+ StreamableHTTPClientTransport: transport.StreamableHTTPClientTransport,
1154
+ ListToolsResultSchema: types.ListToolsResultSchema,
1155
+ ToolListChangedNotificationSchema: types.ToolListChangedNotificationSchema,
1156
+ // Same raw record schema dsh-mcp-client builds (zod v4 `z` namespace from 'zod/v4').
1157
+ RawCallToolResultSchema: zod.z.record(zod.z.string(), zod.z.unknown()),
1158
+ }
1159
+ })()
1160
+ return mcpSdkPromise
1161
+ }
1162
+
1163
+ // Deterministic public name for one discovered MCP tool (see naming contract above).
1164
+ // The 64-char function-name limit is measured against the PUBLIC name. Two defects
1165
+ // this bridge's own tests caught:
1166
+ // - the old `normalized === joined` guard threw away a perfectly valid readable
1167
+ // name whenever normalization changed anything ("get.issues/v2" → hashed), and
1168
+ // - the truncation was taken from the RAW name, silently dropping the
1169
+ // linear_mcp_ namespace prefix that keeps these tools distinct from every
1170
+ // other plugin's. Truncate the prefixed name instead.
1171
+ function publicToolName(rawName) {
1172
+ const joined = `${MCP_NAME_PREFIX}${rawName}`
1173
+ const normalized = joined.replace(INVALID_NAME_CHARS, '_')
1174
+ if (normalized.length <= MAX_PUBLIC_NAME_LENGTH) return normalized
1175
+ const hash = createHash('sha256').update(`linear\0${rawName}`).digest('hex').slice(0, HASH_LENGTH)
1176
+ return `${normalized.slice(0, MAX_PUBLIC_NAME_LENGTH - HASH_LENGTH - 1)}_${hash}`
1177
+ }
1178
+
1179
+ // Keep a supported advertised output schema; unsupported MCP vocabulary falls
1180
+ // back to unconstrained (annotation-only), exactly like dsh-mcp-client.
1181
+ function supportedOutputSchema(candidate) {
1182
+ if (candidate === undefined) return undefined
1183
+ try {
1184
+ assertSupportedJsonSchema(candidate)
1185
+ return candidate
1186
+ } catch {
1187
+ return undefined
1188
+ }
1189
+ }
1190
+
1191
+ // Canonical output contract for one proxied MCP tool.
1192
+ function createMcpOutput(rawName, structuredSchema) {
1193
+ return {
1194
+ schema: {
1195
+ type: 'object',
1196
+ properties: {
1197
+ content: { type: 'array', items: {} },
1198
+ structuredContent: structuredSchema ?? {},
1199
+ },
1200
+ required: structuredSchema === undefined ? ['content'] : ['content', 'structuredContent'],
1201
+ additionalProperties: false,
1202
+ },
1203
+ render(_args, value) {
1204
+ return [{ type: 'text', text: extractText(value.content, rawName) }]
1205
+ },
1206
+ }
1207
+ }
1208
+
1209
+ // ---------------------------------------------------------------------------
1210
+ // Image-result projection (parity with @deepseek-ai/dsh-mcp-client's
1211
+ // prepareImageProjection / finalizeContent pair)
1212
+ //
1213
+ // An MCP result may carry `image` blocks holding raw base64. The harness model
1214
+ // vocabulary carries images as DURABLE attachment references, not inline bytes,
1215
+ // so the bridge: (1) returns the canonical raw value unchanged — programmatic
1216
+ // callers keep every byte; (2) prepares a projection that swaps each image for
1217
+ // its stored reference through the host's own admission pipeline
1218
+ // (`ctx.attachments.saveImages`, which owns the count / aggregate-byte /
1219
+ // media-type limits); and (3) hands the harness that content at the last mile
1220
+ // through `finalizeContent`. Any refusal — an invalid block, no mounted store,
1221
+ // a model route without image input, or an admission rejection — keeps the
1222
+ // existing text projection and only replaces the image with a diagnostic.
1223
+ // Text and error behaviour are unchanged either way.
1224
+ // ---------------------------------------------------------------------------
1225
+
1226
+ /** Raster formats the durable attachment vocabulary supports. */
1227
+ const IMAGE_MEDIA_TYPES = ['image/png', 'image/jpeg', 'image/webp', 'image/gif']
1228
+
1229
+ /** Canonical RFC 4648 base64, excluding whitespace and URL-safe aliases. */
1230
+ const CANONICAL_BASE64 = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/
1231
+
1232
+ /** Narrow one JSON value to a string-keyed object (untrusted MCP content). */
1233
+ function isRecord(value) {
1234
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
1235
+ }
1236
+
1237
+ /** Narrow a declared MIME string to the durable image vocabulary. */
1238
+ function isImageMediaType(value) {
1239
+ return IMAGE_MEDIA_TYPES.includes(value)
1240
+ }
1241
+
1242
+ /** Whether an untrusted MCP content array contains a declared image block. */
1243
+ function containsImage(content) {
1244
+ return Array.isArray(content) && content.some((value) => isRecord(value) && value.type === 'image')
1245
+ }
1246
+
1247
+ /** Decode one untrusted MCP image block without accepting base64 aliases. */
1248
+ function decodeImage(block) {
1249
+ if (block.mimeType === undefined || !isImageMediaType(block.mimeType)) throw new Error('the declared media type is not PNG, JPEG, WebP, or GIF')
1250
+ // `typeof` first: an untrusted block may carry a number or object where the
1251
+ // spec declares a base64 string, and CANONICAL_BASE64.test() would coerce it.
1252
+ if (typeof block.data !== 'string' || !CANONICAL_BASE64.test(block.data)) throw new Error('the image data is not canonical base64')
1253
+ const data = Buffer.from(block.data, 'base64')
1254
+ if (data.toString('base64') !== block.data) throw new Error('the image data is not canonical base64')
1255
+ return { data, mediaType: block.mimeType }
1256
+ }
1257
+
1258
+ /** Stable diagnostic text for an image block that was not admitted. */
1259
+ function imageDiagnostic(block, reason) {
1260
+ return `[image unavailable: ${block?.mimeType ?? 'unknown media type'}; ${reason}; raw image data remains available to programmatic callers]`
1261
+ }
1262
+
1263
+ /**
1264
+ * Recognize a caller-correctable image admission refusal from the mounted
1265
+ * attachment store WITHOUT importing @deepseek-ai/dsh-attachment: the plugin
1266
+ * resolves its peers by absolute file:// URL, and that package is not one of
1267
+ * them. Its AttachmentError class deliberately re-implements the HarnessError
1268
+ * shape so consumers route on `code`, never on the prototype chain — the same
1269
+ * rule that makes it cross duplicate package installations works across a
1270
+ * module boundary we do not own. Codes are copied from that package's
1271
+ * IMAGE_ADMISSION_ERROR_CODES (see THIRD_PARTY_NOTICES.md).
1272
+ */
1273
+ const IMAGE_ADMISSION_ERROR_CODES = new Set([
1274
+ 'TOO_MANY_IMAGES',
1275
+ 'IMAGES_TOO_LARGE',
1276
+ 'UNSUPPORTED_IMAGE_TYPE',
1277
+ 'INVALID_IMAGE_BASE64',
1278
+ 'INVALID_IMAGE',
1279
+ 'IMAGE_TYPE_MISMATCH',
1280
+ 'IMAGE_TOO_LARGE',
1281
+ 'IMAGE_TOO_MANY_PIXELS',
1282
+ 'IMAGE_DIMENSION_TOO_LARGE',
1283
+ ])
1284
+
1285
+ function isImageAdmissionError(error) {
1286
+ return error instanceof Error && typeof error.code === 'string' && IMAGE_ADMISSION_ERROR_CODES.has(error.code)
1287
+ }
1288
+
1289
+ /**
1290
+ * Prove that the route which will read this result accepts image input, then
1291
+ * hand back the durable store. Positive proof only: no mounted store, an
1292
+ * unresolvable route, or a model that does not declare image input all refuse.
1293
+ * @param ctx - plugin context carrying optional attachment and model services.
1294
+ * @param exec - exact tool execution whose agent supplies the latest route.
1295
+ * @returns the mounted attachment store.
1296
+ */
1297
+ async function resolveImageAdmission(ctx, exec) {
1298
+ const attachments = typeof ctx?.get === 'function' ? ctx.get('attachments') : undefined
1299
+ if (attachments === undefined) throw new Error('no attachment store is mounted')
1300
+ const routed = exec.agent?.session?.requestHeader?.()?.config
1301
+ const provider = routed?.provider ?? exec.agent?.options?.provider
1302
+ const model = routed?.model ?? exec.agent?.options?.model
1303
+ const llm = typeof ctx.get === 'function' ? ctx.get('llm') : undefined
1304
+ if (provider === undefined || model === undefined || llm === undefined) throw new Error('the current model route could not be resolved')
1305
+ let info
1306
+ try {
1307
+ info = await llm.resolveModelInfo(provider, model, exec.signal)
1308
+ } catch {
1309
+ throw new Error('the current model route could not be verified')
1310
+ }
1311
+ if (info?.inputModalities === undefined || !info.inputModalities.includes('image')) throw new Error(`model "${model}" does not declare image input`)
1312
+ if (exec.signal?.aborted) throw new Error('the tool call was canceled before image storage')
1313
+ return attachments
1314
+ }
1315
+
1316
+ /**
1317
+ * Project ordered MCP blocks into the core content vocabulary. Text-like runs
1318
+ * are newline-coalesced; admitted images split those runs at their original
1319
+ * position. This is also what `extractText` runs through, so the text half of
1320
+ * the projection is exactly the pre-existing `extractText` string.
1321
+ */
1322
+ function projectContent(mcpContent, toolName, image = (block) => ({ type: 'text', text: imageDiagnostic(block, 'this result was not admitted to durable model context') })) {
1323
+ const projected = []
1324
+ const text = []
1325
+ const flushText = () => {
1326
+ if (text.length === 0) return
1327
+ projected.push({ type: 'text', text: text.splice(0).join('\n') })
1328
+ }
1329
+ for (const [index, value] of (Array.isArray(mcpContent) ? mcpContent : []).entries()) {
1330
+ if (!isRecord(value)) {
1331
+ text.push('[unsupported MCP content block: expected an object]')
1332
+ continue
1333
+ }
1334
+ switch (value.type) {
1335
+ case 'text':
1336
+ if (value.text !== undefined) text.push(value.text)
1337
+ break
1338
+ case 'image':
1339
+ flushText()
1340
+ projected.push(image(value, index))
1341
+ break
1342
+ case 'resource_link':
1343
+ if (value.name === undefined || value.uri === undefined) text.push('[resource link unavailable: the MCP block is missing its name or URI]')
1344
+ else text.push(`Resource link: ${value.name} (${value.uri})`)
1345
+ break
1346
+ case 'audio':
1347
+ text.push(`[audio result unsupported: ${value.mimeType ?? 'unknown media type'}]`)
1348
+ break
1349
+ case 'resource':
1350
+ text.push('[embedded resource unsupported; raw resource data remains available to programmatic callers]')
1351
+ break
1352
+ default:
1353
+ text.push(`[unsupported MCP content type: ${value.type}]`)
1354
+ }
1355
+ }
1356
+ flushText()
1357
+ return projected.length > 0 ? projected : [{ type: 'text', text: `(${toolName} returned no model-visible content)` }]
1358
+ }
1359
+
1360
+ // Extract text from an MCP content array into one string (network trust
1361
+ // boundary: spec-required fields are guarded with fallbacks).
1362
+ function extractText(mcpContent, toolName) {
1363
+ return projectContent(mcpContent, toolName).map((block) => block.text).join('\n')
1364
+ }
1365
+
1366
+ /**
1367
+ * Decode, preflight, and durably save one MCP result's ordered image batch.
1368
+ * Any refusal projects every image as text while retaining the canonical raw
1369
+ * value for programmatic callers.
1370
+ */
1371
+ async function prepareImageProjection(ctx, exec, content, toolName) {
1372
+ const decoded = []
1373
+ const validationErrors = new Map()
1374
+ const imageIndexes = []
1375
+ for (const [index, value] of content.entries()) {
1376
+ if (!isRecord(value) || value.type !== 'image') continue
1377
+ imageIndexes.push(index)
1378
+ try {
1379
+ decoded.push(decodeImage(value))
1380
+ } catch (error) {
1381
+ validationErrors.set(index, error.message)
1382
+ }
1383
+ }
1384
+ if (validationErrors.size > 0) {
1385
+ return projectContent(content, toolName, (block, index) => ({
1386
+ type: 'text',
1387
+ text: imageDiagnostic(block, validationErrors.get(index) ?? 'another image in the same result was invalid'),
1388
+ }))
1389
+ }
1390
+ let attachments
1391
+ try {
1392
+ attachments = await resolveImageAdmission(ctx, exec)
1393
+ } catch (error) {
1394
+ const reason = error?.message ?? String(error)
1395
+ return projectContent(content, toolName, (block) => ({ type: 'text', text: imageDiagnostic(block, reason) }))
1396
+ }
1397
+ try {
1398
+ const refs = await attachments.saveImages(decoded)
1399
+ const byIndex = new Map(imageIndexes.map((index, offset) => [index, refs[offset]]))
1400
+ return projectContent(content, toolName, (_block, index) => ({ type: 'image', attachment: byIndex.get(index) }))
1401
+ } catch (error) {
1402
+ const reason = isImageAdmissionError(error) ? `image admission rejected the result: ${error.message}` : 'durable image storage rejected the result'
1403
+ return projectContent(content, toolName, (block) => ({ type: 'text', text: imageDiagnostic(block, reason) }))
1404
+ }
1405
+ }
1406
+
1407
+ /**
1408
+ * The model-facing half of one MCP result — this bridge's `finalizeContent`.
1409
+ * It follows the upstream dsh-mcp-client shape exactly: the projection is
1410
+ * prepared in `execute` (the only async stage of the pipeline), keyed in a
1411
+ * module-local WeakMap on the exact `exec` object, and read back by the
1412
+ * synchronous finalizer the registry snapshots from the REGISTERED definition
1413
+ * at call start. Upstream's per-execution guards are preserved verbatim: an
1414
+ * error outcome is never rewritten, and a projection is applied only when the
1415
+ * outcome still carries what execute returned — `result.value` deep-equals the
1416
+ * recorded canonical value AND `result.content` deep-equals the recorded
1417
+ * rendered fallback. A result another wrapper rewrote or replaced keeps its own
1418
+ * content (returning undefined preserves it). Each projection is consumed on the
1419
+ * first read, exactly like upstream's `projections.delete(exec)`.
1420
+ * - image bytes always stay in the canonical `value` for programmatic
1421
+ * callers; only the model-visible content carries durable references;
1422
+ * - every refusal leaves the pre-existing text projection in place.
1423
+ * The callback is total: it never throws, and returns undefined to preserve.
1424
+ */
1425
+
1426
+ /** Prepared projection plus the two recorded values upstream compares against. */
1427
+ const imageProjections = new WeakMap()
1428
+
1429
+ function projectMcpResultContent(exec, result) {
1430
+ try {
1431
+ const prepared = imageProjections.get(exec)
1432
+ if (prepared === undefined) return undefined
1433
+ // One projection per execution: read it once and release the key, so a
1434
+ // replayed finalizer for an already-settled call can never re-apply it. The
1435
+ // WeakMap entry is inert after that anyway — its identity is the exec object
1436
+ // the registry hands this callback, so nothing else can look it up.
1437
+ imageProjections.delete(exec)
1438
+ // Upstream's error guard: an outcome the registry turned into an error — a
1439
+ // throw, or a cancellation it replaced this value with — is left alone.
1440
+ if (result?.isError === true) return undefined
1441
+ // Upstream's per-exec guard: the outcome still carries what execute returned.
1442
+ if (!isDeepStrictEqual(result.value, prepared.value)) return undefined
1443
+ if (!isDeepStrictEqual(result.content, prepared.fallback)) return undefined
1444
+ return prepared.projected
1445
+ } catch {
1446
+ return undefined
1447
+ }
1448
+ }
1449
+
1450
+ // Credential-leak guard for diagnostics: strip anything that looks like a
1451
+ // bearer/API token before an error message reaches a log line or the model.
1452
+ // The li_ shape must match the WHOLE token run: Linear keys are
1453
+ // "li_api_<base64-ish body>" with '_' inside the body, so the old \b-anchored
1454
+ // /\bli_[A-Za-z0-9]+\b/ stopped at the first inner underscore and leaked the
1455
+ // entire secret body past the guard.
1456
+ const TOKEN_SHAPES = [/Bearer\s+[A-Za-z0-9._~+/-]+=*/gi, /\bli_[A-Za-z0-9_]+/g, /\bsk-[A-Za-z0-9]+/g]
1457
+ function redactSecrets(text) {
1458
+ let out = String(text)
1459
+ for (const re of TOKEN_SHAPES) out = out.replace(re, '[redacted]')
1460
+ return out
1461
+ }
1462
+
1463
+ /**
1464
+ * Validate raw reconnect config and apply defaults. Re-judges every bound
1465
+ * because programmatic construction may bypass Schemastery normalization.
1466
+ */
1467
+ function resolveReconnectPolicy(config, path) {
1468
+ if (config !== undefined) {
1469
+ for (const key of Object.keys(config)) {
1470
+ if (!Object.hasOwn(RECONNECT_DEFAULTS, key)) throw new Error(`${path}.${key} is not a reconnect option`)
1471
+ }
1472
+ }
1473
+ const enabled = config?.enabled ?? RECONNECT_DEFAULTS.enabled
1474
+ const initialDelayMs = config?.initialDelayMs ?? RECONNECT_DEFAULTS.initialDelayMs
1475
+ const maxDelayMs = config?.maxDelayMs ?? RECONNECT_DEFAULTS.maxDelayMs
1476
+ const maxAttempts = config?.maxAttempts ?? RECONNECT_DEFAULTS.maxAttempts
1477
+ if (!Number.isFinite(initialDelayMs) || initialDelayMs <= 0) throw new Error(`${path}.initialDelayMs must be a positive finite number`)
1478
+ if (!Number.isFinite(maxDelayMs) || maxDelayMs <= 0) throw new Error(`${path}.maxDelayMs must be a positive finite number`)
1479
+ if (initialDelayMs > maxDelayMs) throw new Error(`${path}.initialDelayMs must be less than or equal to maxDelayMs`)
1480
+ if (!Number.isInteger(maxAttempts) || maxAttempts < 1) throw new Error(`${path}.maxAttempts must be a positive integer`)
1481
+ return Object.freeze({ enabled, initialDelayMs, maxDelayMs, maxAttempts })
1482
+ }
1483
+
1484
+ /**
1485
+ * One supervised MCP connection: connect → discover → register; re-discover on
1486
+ * list_changed; rebuild the generation on connection loss with bounded backoff.
1487
+ *
1488
+ * Registration lifecycle fit (verified against cordis 4.0.2): a plugin `apply`
1489
+ * may be async — the fiber awaits it (`_execute`/`_reload`) and collects effect
1490
+ * disposers registered during it — so discovery at apply time is compatible.
1491
+ * Later re-syncs run OUTSIDE apply: every registration is owned by an explicit
1492
+ * `ctx.effect()` whose disposer unregisters that generation's tools, and the
1493
+ * reconnect timer is `unref()`ed so a pending retry never keeps the process alive.
1494
+ */
1495
+ function startMcpConnection(ctx, config) {
1496
+ const label = 'linear-mcp'
1497
+ const policy = resolveReconnectPolicy(config.reconnect, 'linear mcp: reconnect')
1498
+ const toolCallTimeoutMs = config.toolCallTimeoutMs ?? DEFAULT_MCP_TOOL_CALL_TIMEOUT_MS
1499
+
1500
+ let disposed = false
1501
+ /** The live generation's Client (undefined while down/backing off). */
1502
+ let client
1503
+ /** Live registrations owned by the current generation. */
1504
+ let disposers = new Map()
1505
+ let reconnectTimer
1506
+ /** Consecutive failed connection attempts within the current outage. */
1507
+ let failedAttempts = 0
1508
+ /** When the current generation finished connect + initial discovery. */
1509
+ let connectedAt
1510
+ /** The real error from the first attempt, for apply-time diagnostics. */
1511
+ let firstAttemptError
1512
+ /** Serializes every discovery swap so two can never interleave. */
1513
+ let syncChain = Promise.resolve()
1514
+
1515
+ const isCurrent = (generation) => !disposed && client === generation
1516
+
1517
+ // One effect slot owns ALL registrations of the current generation. It is
1518
+ // armed exactly once, during apply (so it belongs to the plugin fiber), and
1519
+ // always unregisters whatever is live at teardown — never a stale set.
1520
+ // (Cordis `_execute` rejects a *returned* function as "Invalid effect", so
1521
+ // the disposer is collected by returning an iterable of disposers.)
1522
+ ctx.effect(() => [
1523
+ () => {
1524
+ for (const dispose of disposers.values()) dispose()
1525
+ disposers = new Map()
1526
+ },
1527
+ ], 'linear.mcp.tools')
1528
+
1529
+ /** Fetch the complete next generation, then atomically swap registrations. */
1530
+ async function syncTools(generation) {
1531
+ const sdk = await loadMcpSdk()
1532
+ const definitions = new Map()
1533
+ const seenCursors = new Set()
1534
+ let cursor
1535
+ do {
1536
+ // Uncached paginated tools/list (same idiom as dsh-mcp-client): a raw
1537
+ // request keeps the SDK's per-page schema cache from merging pages.
1538
+ const response = await generation.request(
1539
+ { method: 'tools/list', ...(cursor === undefined ? {} : { params: { cursor } }) },
1540
+ sdk.ListToolsResultSchema,
1541
+ )
1542
+ for (const tool of response.tools) {
1543
+ const publicName = publicToolName(tool.name)
1544
+ if (definitions.has(publicName)) {
1545
+ throw new Error(`${label}: server listed tool "${tool.name}" more than once — invalid tool list`)
1546
+ }
1547
+ definitions.set(publicName, createMcpDefinition(ctx, generation, sdk, publicName, tool, config))
1548
+ }
1549
+ cursor = response.nextCursor
1550
+ if (cursor) {
1551
+ if (seenCursors.has(cursor)) throw new Error(`${label}: server repeated a tools/list continuation cursor — invalid tool list`)
1552
+ seenCursors.add(cursor)
1553
+ }
1554
+ } while (cursor)
1555
+ // Fetch phase succeeded — only now swap: dispose the old generation, then
1556
+ // register the new one. A registration conflict rolls the partial back.
1557
+ for (const dispose of disposers.values()) dispose()
1558
+ const next = new Map()
1559
+ try {
1560
+ for (const [publicName, definition] of definitions) next.set(publicName, ctx.tools.register(definition))
1561
+ } catch (error) {
1562
+ for (const dispose of next.values()) dispose()
1563
+ disposers = new Map()
1564
+ ctx.logger.error(`${label}: tool registration failed, no tools registered: ${String(error)}`)
1565
+ throw error
1566
+ }
1567
+ disposers = next
1568
+ return next.size
1569
+ }
1570
+
1571
+ function enqueueSync(generation) {
1572
+ const run = syncChain.then(async () => {
1573
+ if (!isCurrent(generation)) return
1574
+ await syncTools(generation)
1575
+ })
1576
+ syncChain = run.catch(() => {})
1577
+ return run
1578
+ }
1579
+
1580
+ /** One disconnect decision per generation (idempotent under racing close/error). */
1581
+ function generationDown(generation) {
1582
+ if (!isCurrent(generation)) return
1583
+ client = undefined
1584
+ scheduleReconnect()
1585
+ }
1586
+
1587
+ function scheduleReconnect() {
1588
+ const lostEstablishedConnection = connectedAt !== undefined
1589
+ if (!policy.enabled) {
1590
+ ctx.logger.error(`${label}: ${lostEstablishedConnection ? 'connection lost and reconnect is disabled' : 'connection failed and reconnect is disabled'} — Linear MCP tools are unavailable until the plugin reloads or the Host restarts`)
1591
+ return
1592
+ }
1593
+ // A connection that stayed up past maxDelayMs closes the outage, so a
1594
+ // crash-loop still exhausts the cap instead of restarting forever.
1595
+ if (connectedAt !== undefined && Date.now() - connectedAt >= policy.maxDelayMs) failedAttempts = 0
1596
+ connectedAt = undefined
1597
+ failedAttempts += 1
1598
+ if (failedAttempts > policy.maxAttempts) {
1599
+ syncChain = syncChain.then(() => {
1600
+ for (const dispose of disposers.values()) dispose()
1601
+ disposers = new Map()
1602
+ })
1603
+ ctx.logger.error(`${label}: giving up after ${policy.maxAttempts} consecutive failed reconnect attempts — Linear MCP tools unregistered; reload the plugin or restart the Host to reconnect`)
1604
+ return
1605
+ }
1606
+ const delayMs = Math.min(policy.maxDelayMs, policy.initialDelayMs * 2 ** (failedAttempts - 1))
1607
+ ctx.logger.warn(`${label}: ${lostEstablishedConnection ? 'connection lost; reconnecting' : 'connection failed; retrying'} in ${delayMs}ms (attempt ${failedAttempts}/${policy.maxAttempts})`)
1608
+ reconnectTimer = setTimeout(() => {
1609
+ reconnectTimer = undefined
1610
+ connectGeneration(false)
1611
+ }, delayMs)
1612
+ reconnectTimer.unref()
1613
+ }
1614
+
1615
+ /** One connection attempt. Never rejects; every failure funnels through generationDown. */
1616
+ async function connectGeneration(startup) {
1617
+ let sdk
1618
+ try {
1619
+ sdk = await loadMcpSdk()
1620
+ } catch (error) {
1621
+ if (firstAttemptError === undefined) firstAttemptError = error
1622
+ ctx.logger.error(`${label}: the MCP SDK could not be loaded — Linear MCP tools are unavailable: ${String(error)}`)
1623
+ return
1624
+ }
1625
+ const generation = new sdk.Client({ name: 'dsh-linear-mcp-bridge', version: '0.1.0' }, { capabilities: {} })
1626
+ client = generation
1627
+ // A close observed DURING the attempt is handled by the attempt's own
1628
+ // settle path; only a post-settle close triggers the reconnect decision.
1629
+ let attemptSettled = false
1630
+ generation.onclose = () => {
1631
+ if (attemptSettled && isCurrent(generation)) generationDown(generation)
1632
+ }
1633
+ generation.onerror = (error) => {
1634
+ // Transport-level errors are logged; a dead transport also fires onclose.
1635
+ ctx.logger.warn(`${label}: transport error: ${redactSecrets(error?.message ?? error)}`)
1636
+ }
1637
+ generation.setNotificationHandler(sdk.ToolListChangedNotificationSchema, async () => {
1638
+ if (!isCurrent(generation)) return
1639
+ ctx.logger.info(`${label}: tool list changed, re-syncing`)
1640
+ try {
1641
+ await enqueueSync(generation)
1642
+ } catch (error) {
1643
+ if (!disposed) ctx.logger.error(`${label}: tool re-sync failed: ${String(error)}`)
1644
+ }
1645
+ })
1646
+ // Credentials live ONLY in this header map, rebuilt per generation.
1647
+ const headers = { Authorization: `Bearer ${config.token}` }
1648
+ try {
1649
+ await generation.connect(new sdk.StreamableHTTPClientTransport(new URL(config.url), { requestInit: { headers } }))
1650
+ if (generation.transport === undefined) {
1651
+ // The transport closed itself during connect(): settle the attempt and
1652
+ // let the reconnect scheduler take over.
1653
+ attemptSettled = true
1654
+ if (isCurrent(generation)) generationDown(generation)
1655
+ return
1656
+ }
1657
+ if (startup) {
1658
+ // Startup registration failure is fatal to apply so the operator sees it.
1659
+ const count = await syncTools(generation)
1660
+ ctx.logger.info(`${label}: connected to ${config.url}, registered ${count} Linear MCP tools`)
1661
+ } else {
1662
+ await enqueueSync(generation)
1663
+ if (failedAttempts > 0) ctx.logger.info(`${label}: reconnected and re-synced tools (attempt ${failedAttempts}/${policy.maxAttempts})`)
1664
+ }
1665
+ } catch (error) {
1666
+ if (firstAttemptError === undefined) firstAttemptError = error
1667
+ if (isCurrent(generation)) ctx.logger.warn(`${label}: connection attempt failed: ${authHint(error)}`)
1668
+ try {
1669
+ await generation.close()
1670
+ } catch {}
1671
+ attemptSettled = true
1672
+ if (!isCurrent(generation)) return
1673
+ generationDown(generation)
1674
+ return
1675
+ }
1676
+ attemptSettled = true
1677
+ if (!isCurrent(generation)) return
1678
+ connectedAt = Date.now()
1679
+ }
1680
+
1681
+ // Surface an actionable hint for auth failures without echoing any secret.
1682
+ function authHint(error) {
1683
+ const text = String(error?.message ?? error)
1684
+ if (/401|Unauthorized/i.test(text)) return `${text} — check that the Linear MCP token (LINEAR_MCP_TOKEN / config mcp.token) is valid for ${config.url}`
1685
+ return text
1686
+ }
1687
+
1688
+ const settling = connectGeneration(true)
1689
+ ctx.effect(() => [
1690
+ () => {
1691
+ disposed = true
1692
+ if (reconnectTimer !== undefined) {
1693
+ clearTimeout(reconnectTimer)
1694
+ reconnectTimer = undefined
1695
+ }
1696
+ const current = client
1697
+ client = undefined
1698
+ return async () => {
1699
+ if (current !== undefined) {
1700
+ try {
1701
+ await current.close()
1702
+ } catch {}
1703
+ }
1704
+ await settling
1705
+ await syncChain
1706
+ for (const dispose of disposers.values()) dispose()
1707
+ disposers = new Map()
1708
+ }
1709
+ },
1710
+ ], 'linear.mcp.connection')
1711
+
1712
+ return {
1713
+ /** Settles after the startup attempt; carries its error without throwing. */
1714
+ ready: settling.then(() => firstAttemptError),
1715
+ }
1716
+ }
1717
+
1718
+ /** Build one harness tool definition for a discovered MCP tool. */
1719
+ function createMcpDefinition(ctx, client, sdk, publicName, tool, config) {
1720
+ const rawName = tool.name
1721
+ return {
1722
+ name: publicName,
1723
+ description: tool.description ?? '',
1724
+ // Pass the DISCOVERED input schema straight through; this plugin never
1725
+ // hand-models Linear's MCP API surface.
1726
+ parameters: tool.inputSchema ?? {},
1727
+ output: createMcpOutput(rawName, supportedOutputSchema(tool.outputSchema)),
1728
+ timeoutMs: config.toolCallTimeoutMs ?? DEFAULT_MCP_TOOL_CALL_TIMEOUT_MS,
1729
+ // Last-mile model-facing projection for image-bearing results (see
1730
+ // projectMcpResultContent). Declared on the definition so the registry
1731
+ // snapshots it per execution; the value it never touches stays canonical.
1732
+ finalizeContent(exec, result) {
1733
+ return projectMcpResultContent(exec, result)
1734
+ },
1735
+ async execute(args, exec) {
1736
+ const result = await client.request(
1737
+ { method: 'tools/call', params: { name: rawName, arguments: typeof args === 'object' && args !== null ? args : {} } },
1738
+ // Raw record schema (same as dsh-mcp-client): the bridge owns result
1739
+ // validation instead of the SDK pre-validating a different contract.
1740
+ sdk.RawCallToolResultSchema ?? { parse: (v) => v },
1741
+ { signal: exec.signal, timeout: config.toolCallTimeoutMs ?? DEFAULT_MCP_TOOL_CALL_TIMEOUT_MS },
1742
+ )
1743
+ if (result.isError === true) throw new Error(redactSecrets(extractText(result.content, rawName)))
1744
+ const content = Array.isArray(result.content) ? result.content : []
1745
+ // Awaited HERE — the only async stage of the pipeline. finalizeContent is
1746
+ // synchronous, so a projection started there could never land: an
1747
+ // image-bearing result must be projected before this body returns.
1748
+ if (containsImage(content)) {
1749
+ const projected = await prepareImageProjection(ctx, exec, content, rawName)
1750
+ // Keyed on the exact execution the registry snapshots its finalizer for.
1751
+ // The two recorded values are what the finalizer compares the outcome
1752
+ // against: this body's canonical value, and the model-visible fallback —
1753
+ // `render` is createMcpOutput's text-only projection of that same value,
1754
+ // so its snapshot equals this one block array. A structuredContent field
1755
+ // rides alongside `content` in the value but never in the rendered
1756
+ // content, so it is recorded on the value half only.
1757
+ // The recorded value must be EXACTLY what this body returns below —
1758
+ // the finalizer deep-equals `result.value` against it, so a recorded
1759
+ // `{ content }` never matches an outcome that carries structuredContent
1760
+ // alongside it, and the projection would be silently dropped (the model
1761
+ // would see the text diagnostic while the raw bytes stayed canonical).
1762
+ const value = {
1763
+ content,
1764
+ ...(result.structuredContent !== undefined ? { structuredContent: result.structuredContent } : {}),
1765
+ }
1766
+ imageProjections.set(exec, { projected, value, fallback: [{ type: 'text', text: extractText(content, rawName) }] })
1767
+ return value
1768
+ }
1769
+ return {
1770
+ content,
1771
+ ...(result.structuredContent !== undefined ? { structuredContent: result.structuredContent } : {}),
1772
+ }
1773
+ },
1774
+ }
1775
+ }
1776
+
1777
+ // ---------------------------------------------------------------------------
1778
+ // Plugin surface
1779
+ // ---------------------------------------------------------------------------
1780
+
1781
+ /**
1782
+ * Plugin config:
1783
+ * apiKey: Linear personal API key (li_api_...) for the GraphQL tools.
1784
+ * Falls back to the LINEAR_API_KEY environment variable when unset.
1785
+ * mcp: optional MCP-bridge config (Linear hosted MCP server parity):
1786
+ * token — bearer token; falls back to LINEAR_MCP_TOKEN, then to
1787
+ * the same key resolution as `apiKey` (config apiKey or
1788
+ * LINEAR_API_KEY). The value is used ONLY in the
1789
+ * Authorization header of requests to `url`.
1790
+ * url — Streamable HTTP endpoint (default https://mcp.linear.app/mcp).
1791
+ * toolCallTimeoutMs / reconnect — call timeout and bounded backoff.
1792
+ *
1793
+ * The getter above mirrors resolveKey's precedence: a config.apiKey passed in the
1794
+ * patch row wins over the env var. The getter itself has no access to the
1795
+ * config object (it's a module-level singleton), so it only reports the env
1796
+ * var; the authoritative resolution happens in resolveKey which checks
1797
+ * `config?.apiKey` first.
1798
+ */
1799
+ // Cordis 0.1.5 validates a plugin's `Config` as a Standard Schema (it calls
1800
+ // Config["~standard"].validate(...)). The real API key still comes from the
1801
+ // LINEAR_API_KEY env var (see resolveKey); this schema only needs to accept an
1802
+ // optional `apiKey` string so a patch row may also supply one. `.default('')`
1803
+ // makes a missing field valid; the empty value is ignored by resolveKey, which
1804
+ // prefers config.apiKey only when it is non-empty and otherwise falls back to env.
1805
+ const Reconnect = z.object({
1806
+ enabled: z.boolean().default(RECONNECT_DEFAULTS.enabled),
1807
+ initialDelayMs: z.number().min(1).max(2147483647).default(RECONNECT_DEFAULTS.initialDelayMs),
1808
+ maxDelayMs: z.number().min(1).max(2147483647).default(RECONNECT_DEFAULTS.maxDelayMs),
1809
+ maxAttempts: z.number().step(1).min(1).max(Number.MAX_SAFE_INTEGER).default(RECONNECT_DEFAULTS.maxAttempts),
1810
+ })
1811
+
1812
+ const McpConfig = z.object({
1813
+ token: z.string().default(''),
1814
+ url: z.string().default(DEFAULT_MCP_URL),
1815
+ toolCallTimeoutMs: z.number().min(1).default(DEFAULT_MCP_TOOL_CALL_TIMEOUT_MS),
1816
+ reconnect: Reconnect,
1817
+ })
1818
+
1819
+ const Config = z.object({
1820
+ apiKey: z.string().default(''),
1821
+ mcp: McpConfig,
1822
+ })
1823
+
1824
+ /**
1825
+ * Resolve the MCP bearer token without ever printing it. Precedence:
1826
+ * config.mcp.token → LINEAR_MCP_TOKEN → the GraphQL key resolution chain.
1827
+ */
1828
+ function resolveMcpToken(config) {
1829
+ const token = config?.mcp?.token || process.env.LINEAR_MCP_TOKEN || config?.apiKey || process.env.LINEAR_API_KEY
1830
+ if (!token) throw new Error('Linear MCP token not configured: set the LINEAR_MCP_TOKEN environment variable (or pass `mcp.token` / `apiKey` in plugin config).')
1831
+ return token
1832
+ }
1833
+
1834
+ // The bridge is opt-in and fail-soft: a missing token or an unreachable MCP
1835
+ // server must never take down the GraphQL tool suite that shares this plugin.
1836
+ // LINEAR_MCP_DISABLED=1 turns it off; otherwise it starts only when an MCP
1837
+ // token is configured AND the host ctx supports effect-scoped teardown
1838
+ // (ctx.effect) — a bare test ctx without lifecycle support keeps the bridge off.
1839
+ const MCP_BRIDGE_ENABLED = process.env.LINEAR_MCP_DISABLED !== '1'
1840
+
1841
+ async function apply(ctx, config) {
1842
+ registerLinearTools(ctx, config)
1843
+ if (!MCP_BRIDGE_ENABLED || typeof ctx.effect !== 'function') return
1844
+ let mcpConfig
1845
+ try {
1846
+ mcpConfig = {
1847
+ token: resolveMcpToken(config),
1848
+ url: config?.mcp?.url || DEFAULT_MCP_URL,
1849
+ toolCallTimeoutMs: config?.mcp?.toolCallTimeoutMs,
1850
+ reconnect: config?.mcp?.reconnect,
1851
+ }
1852
+ } catch (err) {
1853
+ ctx.logger.warn(`linear-mcp: bridge disabled — ${err?.message ?? err}`)
1854
+ return
1855
+ }
1856
+ const connection = startMcpConnection(ctx, mcpConfig)
1857
+ // Startup attempt settles inside apply (cordis awaits an async apply), so a
1858
+ // failure here is reported once, clearly, and does not throw the fiber down.
1859
+ await connection.ready
1860
+ }
1861
+
1862
+ // The MCP-bridge internals are exported alongside the plugin surface so the
1863
+ // mock-based bridge tests (test-mcp-bridge.js) can exercise discovery, naming,
1864
+ // auth, forwarding and disposal in isolation. Cordis's namespace unwrap keys off
1865
+ // { name, inject, apply }, so extra named exports are inert for the loader.
1866
+ export {
1867
+ Config,
1868
+ apply,
1869
+ inject,
1870
+ name,
1871
+ // MCP bridge internals (test-visible; no behaviour change)
1872
+ createMcpDefinition,
1873
+ createMcpOutput,
1874
+ extractText,
1875
+ publicToolName,
1876
+ redactSecrets,
1877
+ resolveMcpToken,
1878
+ resolveReconnectPolicy,
1879
+ startMcpConnection,
1880
+ supportedOutputSchema,
1881
+ // Image-result projection internals (test-visible)
1882
+ decodeImage,
1883
+ containsImage,
1884
+ imageDiagnostic,
1885
+ isImageAdmissionError,
1886
+ prepareImageProjection,
1887
+ projectContent,
1888
+ projectMcpResultContent,
1889
+ }