@kb1slnlabs/dsh-linear 0.1.0 → 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 CHANGED
@@ -4,47 +4,58 @@
4
4
  // export — a stray default collapses the module via the Loader's unwrapExports).
5
5
  // `apply` registers model-facing tools through ctx.tools.register.
6
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.
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.
15
13
  //
16
- // Peer deps (@deepseek-ai/dsh-tools, @deepseek-ai/cordis, the MCP SDK) resolve
14
+ // Peer deps (@deepseek-ai/dsh-tools, @deepseek-ai/cordis) resolve
17
15
  // from the dsh install's node_modules — this file lives outside any pnpm tree,
18
16
  // so a plain import would not find them; the resolvers below pin resolution to
19
17
  // the dsh install root by absolute file:// URL.
20
18
  //
21
19
  // 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'
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
+
37
49
  import { createRequire } from 'node:module'
38
50
  import { fileURLToPath, pathToFileURL } from 'node:url'
39
51
  import { homedir } from 'node:os'
40
52
  import { dirname, join } from 'node:path'
41
- import { isDeepStrictEqual } from 'node:util'
42
53
 
43
54
  // The plugin source lives outside any pnpm tree, so a bare import('@deepseek-ai/dsh-tools')
44
55
  // 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
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
48
59
  // when the plugin is installed inside a tree that carries these packages (or one of their
49
60
  // peer locations), plain resolution finds them with no anchoring at all. Only when that
50
61
  // fails do we fall back to resolving from the dsh install root, derived portably from
@@ -120,46 +131,22 @@ function fallbackNodeModulesRoots() {
120
131
  *
121
132
  * Peer dependencies are declared as BOTH `dependencies` and `peerDependencies`, because
122
133
  * 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.
134
+ * dsh install's copy.
128
135
  */
129
136
  function resolveEntry(override, specifier) {
130
137
  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
138
+ return tryResolveFrom(selfPath, specifier) ?? tryResolveFrom(dshInstallAnchor(), specifier) ?? tryResolveFromFromRoots(specifier) ?? specifier
142
139
  }
143
140
 
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
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
159
148
  }
160
149
 
161
- const groupCache = new Map()
162
-
163
150
  function resolveDshToolsEntry() {
164
151
  return resolveEntry(process.env.DSH_LINEAR_TOOLS, '@deepseek-ai/dsh-tools')
165
152
  }
@@ -169,35 +156,7 @@ function resolveDshSchemasteryEntry() {
169
156
  return resolveEntry(process.env.DSH_LINEAR_SCHEMA, '@deepseek-ai/schemastery')
170
157
  }
171
158
 
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())
159
+ const { defineTool } = await import(resolveDshToolsEntry())
201
160
  // schemastery's exports map serves the ESM entry to an import in a tree that provides it;
202
161
  // resolved from the dsh install root the same specifier may land on its CJS entry, which
203
162
  // Node wraps with both `default` and the named bindings. Take whichever shape is real.
@@ -251,6 +210,12 @@ const ISSUE_FIELDS = `
251
210
  url
252
211
  `
253
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
+
254
219
  const COMMENT_FIELDS = `
255
220
  id body createdAt
256
221
  `
@@ -288,6 +253,26 @@ const USER_FIELDS = `
288
253
 
289
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
290
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
+
291
276
  // ---------------------------------------------------------------------------
292
277
  // Entity resolution (resolve-then-mutate)
293
278
  // ---------------------------------------------------------------------------
@@ -343,17 +328,60 @@ async function resolveStateId(key, teamId, stateName) {
343
328
  return nodes[0].id
344
329
  }
345
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
+
346
369
  // Resolve a human identifier ("APT-804") or a UUID to a Linear issue UUID.
347
370
  // IssueFilter has no `identifier` field, so we split the identifier into
348
371
  // team key + number and look the issue up by those, then return its id.
349
- async function findIssueId(key, 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 } = {}) {
350
378
  if (UUID_RE.test(id)) return id
351
379
  const m = id.match(/^(.+?)-(\d+)$/)
352
380
  if (!m) return null
353
381
  const [, teamKey, num] = m
354
382
  const data = await gql(key, `
355
383
  query($team: String!, $num: Float!) {
356
- issues(filter: { team: { key: { eq: $team } }, number: { eq: $num } }) {
384
+ issues(filter: { team: { key: { eq: $team } }, number: { eq: $num } }${includeArchived ? ', includeArchived: true' : ''}) {
357
385
  nodes { id }
358
386
  }
359
387
  }
@@ -361,6 +389,41 @@ async function findIssueId(key, id) {
361
389
  return data.issues.nodes[0]?.id ?? null
362
390
  }
363
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
+
364
427
  // ---------------------------------------------------------------------------
365
428
  // GraphQL tools
366
429
  // ---------------------------------------------------------------------------
@@ -392,12 +455,13 @@ function registerLinearTools(ctx, config) {
392
455
  defineTool({
393
456
  name: 'linear_issue_list',
394
457
  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).',
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 }.',
396
459
  parameters: {
397
460
  team: { type: 'string', description: 'Team key to filter by, e.g. "APT"' },
398
461
  state: { type: 'string', description: 'State name to filter by, e.g. "Done", "In Progress"' },
399
462
  search: { type: 'string', description: 'Free-text search across title/description' },
400
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' },
401
465
  },
402
466
  output: {
403
467
  schema: { type: 'string' },
@@ -405,18 +469,25 @@ function registerLinearTools(ctx, config) {
405
469
  },
406
470
  async execute(args) {
407
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
408
474
  const filterParts = []
409
475
  const vars = {}
410
476
  if (args.team) { filterParts.push(`team: { key: { eq: $team } }`); vars.team = args.team }
411
477
  if (args.state) { filterParts.push(`state: { name: { eq: $state } }`); vars.state = args.state }
412
478
  if (args.search) { filterParts.push(`OR: { title: { containsInsensitive: $search }, description: { containsInsensitive: $search } }`); vars.search = args.search }
413
479
  const filter = filterParts.length ? `filter: { ${filterParts.join(', ')} }` : ''
480
+ if (after !== undefined) vars.after = after
414
481
  const usedVars = Object.keys(vars)
415
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(', ')
416
486
  const data = await gql(key(), `
417
487
  ${varDecl} {
418
- issues(first: ${limit}, ${filter}) {
488
+ issues(first: ${limit}${argList ? `, ${argList}` : ''}) {
419
489
  nodes { ${ISSUE_FIELDS} }
490
+ ${PAGE_INFO_FIELDS}
420
491
  }
421
492
  }
422
493
  `, vars)
@@ -431,7 +502,7 @@ function registerLinearTools(ctx, config) {
431
502
  labels: i.labels.nodes.map((l) => l.name),
432
503
  url: i.url,
433
504
  }))
434
- return JSON.stringify({ count: issues.length, issues }, null, 2)
505
+ return JSON.stringify({ count: issues.length, issues, pageInfo: normalizePageInfo(data.issues.pageInfo) }, null, 2)
435
506
  },
436
507
  }),
437
508
  )
@@ -590,6 +661,41 @@ function registerLinearTools(ctx, config) {
590
661
  }),
591
662
  )
592
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
+
593
699
  // -- Comments ---------------------------------------------------------------
594
700
 
595
701
  register(
@@ -623,10 +729,11 @@ function registerLinearTools(ctx, config) {
623
729
  register(
624
730
  defineTool({
625
731
  name: 'linear_comment_list',
626
- description: 'List comments on a Linear issue (by id or identifier), newest first.',
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 }.',
627
733
  parameters: {
628
734
  issue: { type: 'string', required: true, description: 'Issue id or identifier' },
629
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' },
630
737
  },
631
738
  output: {
632
739
  schema: { type: 'string' },
@@ -634,28 +741,95 @@ function registerLinearTools(ctx, config) {
634
741
  },
635
742
  async execute(args) {
636
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
637
746
  const issueId = await findIssueId(key(), args.issue)
638
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' : ''
639
754
  const data = await gql(key(), `
640
- query($id: String!) {
755
+ ${varDecl} {
641
756
  issue: issue(id: $id) {
642
757
  id
643
- comments(first: ${limit}) {
758
+ comments(first: ${limit}${argList ? `, ${argList}` : ''}) {
644
759
  nodes { ${COMMENT_FIELDS} }
760
+ ${PAGE_INFO_FIELDS}
645
761
  }
646
762
  }
647
763
  }
648
- `, { id: issueId })
764
+ `, vars)
649
765
  if (!data.issue) return JSON.stringify({ error: `Issue not found: ${args.issue}` })
650
766
  return JSON.stringify({
651
767
  issue: args.issue,
652
768
  count: data.issue.comments.nodes.length,
653
769
  comments: data.issue.comments.nodes,
770
+ pageInfo: normalizePageInfo(data.issue.comments.pageInfo),
654
771
  }, null, 2)
655
772
  },
656
773
  }),
657
774
  )
658
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
+
659
833
  // -- Relations --------------------------------------------------------------
660
834
 
661
835
  register(
@@ -702,21 +876,32 @@ function registerLinearTools(ctx, config) {
702
876
  register(
703
877
  defineTool({
704
878
  name: 'linear_team_list',
705
- description: 'List all Linear teams (key, name, description).',
706
- parameters: {},
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
+ },
707
883
  output: {
708
884
  schema: { type: 'string' },
709
885
  render: (_a, v) => [{ type: 'text', text: v }],
710
886
  },
711
- async execute() {
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(', ')
712
896
  const data = await gql(key(), `
713
- {
714
- teams(first: 100) {
897
+ ${varDecl} {
898
+ teams(first: 100${argList ? `, ${argList}` : ''}) {
715
899
  nodes { ${TEAM_FIELDS} }
900
+ ${PAGE_INFO_FIELDS}
716
901
  }
717
902
  }
718
- `)
719
- return JSON.stringify({ count: data.teams.nodes.length, teams: data.teams.nodes }, null, 2)
903
+ `, vars)
904
+ return JSON.stringify({ count: data.teams.nodes.length, teams: data.teams.nodes, pageInfo: normalizePageInfo(data.teams.pageInfo) }, null, 2)
720
905
  },
721
906
  }),
722
907
  )
@@ -724,27 +909,37 @@ function registerLinearTools(ctx, config) {
724
909
  register(
725
910
  defineTool({
726
911
  name: 'linear_state_list',
727
- description: 'List workflow states for a team (e.g. Todo, In Progress, Done).',
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 }.',
728
913
  parameters: {
729
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' },
730
916
  },
731
917
  output: {
732
918
  schema: { type: 'string' },
733
919
  render: (_a, v) => [{ type: 'text', text: v }],
734
920
  },
735
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
736
924
  // Resolve the team to a UUID via the PLURAL `teams` field (the singular
737
925
  // `team` field takes only a UUID id), then list that team's states via
738
926
  // the top-level `workflowStates` field filtered by team id.
739
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(', ')
740
934
  const data = await gql(key(), `
741
- query($teamId: String!) {
742
- workflowStates(filter: { team: { id: { eq: $teamId } } }) {
935
+ ${varDecl} {
936
+ workflowStates(filter: { team: { id: { eq: $teamId } } }${argList ? `, ${argList}` : ''}) {
743
937
  nodes { ${STATE_FIELDS} }
938
+ ${PAGE_INFO_FIELDS}
744
939
  }
745
940
  }
746
- `, { teamId })
747
- return JSON.stringify({ team: args.team, states: data.workflowStates.nodes }, null, 2)
941
+ `, vars)
942
+ return JSON.stringify({ team: args.team, states: data.workflowStates.nodes, pageInfo: normalizePageInfo(data.workflowStates.pageInfo) }, null, 2)
748
943
  },
749
944
  }),
750
945
  )
@@ -752,24 +947,34 @@ function registerLinearTools(ctx, config) {
752
947
  register(
753
948
  defineTool({
754
949
  name: 'linear_label_list',
755
- description: 'List labels for a team.',
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 }.',
756
951
  parameters: {
757
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' },
758
954
  },
759
955
  output: {
760
956
  schema: { type: 'string' },
761
957
  render: (_a, v) => [{ type: 'text', text: v }],
762
958
  },
763
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
764
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(', ')
765
969
  const data = await gql(key(), `
766
- query($teamId: String!) {
767
- issueLabels(filter: { team: { id: { eq: $teamId } } }) {
970
+ ${varDecl} {
971
+ issueLabels(filter: { team: { id: { eq: $teamId } } }${argList ? `, ${argList}` : ''}) {
768
972
  nodes { ${LABEL_FIELDS} }
973
+ ${PAGE_INFO_FIELDS}
769
974
  }
770
975
  }
771
- `, { teamId })
772
- return JSON.stringify({ team: args.team, labels: data.issueLabels.nodes }, null, 2)
976
+ `, vars)
977
+ return JSON.stringify({ team: args.team, labels: data.issueLabels.nodes, pageInfo: normalizePageInfo(data.issueLabels.pageInfo) }, null, 2)
773
978
  },
774
979
  }),
775
980
  )
@@ -777,9 +982,10 @@ function registerLinearTools(ctx, config) {
777
982
  register(
778
983
  defineTool({
779
984
  name: 'linear_project_list',
780
- description: 'List Linear projects (name, description, state, teams).',
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 }.',
781
986
  parameters: {
782
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' },
783
989
  },
784
990
  output: {
785
991
  schema: { type: 'string' },
@@ -787,14 +993,23 @@ function registerLinearTools(ctx, config) {
787
993
  },
788
994
  async execute(args) {
789
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(', ')
790
1004
  const data = await gql(key(), `
791
- {
792
- projects(first: ${limit}) {
1005
+ ${varDecl} {
1006
+ projects(first: ${limit}${argList ? `, ${argList}` : ''}) {
793
1007
  nodes { ${PROJECT_FIELDS} }
1008
+ ${PAGE_INFO_FIELDS}
794
1009
  }
795
1010
  }
796
- `)
797
- return JSON.stringify({ count: data.projects.nodes.length, projects: data.projects.nodes }, null, 2)
1011
+ `, vars)
1012
+ return JSON.stringify({ count: data.projects.nodes.length, projects: data.projects.nodes, pageInfo: normalizePageInfo(data.projects.pageInfo) }, null, 2)
798
1013
  },
799
1014
  }),
800
1015
  )
@@ -832,6 +1047,63 @@ function registerLinearTools(ctx, config) {
832
1047
  }),
833
1048
  )
834
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
+
835
1107
  register(
836
1108
  defineTool({
837
1109
  name: 'linear_label_create',
@@ -874,10 +1146,11 @@ function registerLinearTools(ctx, config) {
874
1146
  register(
875
1147
  defineTool({
876
1148
  name: 'linear_cycle_list',
877
- description: 'List cycles for a team. Returns up to `limit` most recent cycles (default 20, max 50).',
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 }.',
878
1150
  parameters: {
879
1151
  team: { type: 'string', required: true, description: 'Team key, e.g. "APT"' },
880
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' },
881
1154
  },
882
1155
  output: {
883
1156
  schema: { type: 'string' },
@@ -885,14 +1158,23 @@ function registerLinearTools(ctx, config) {
885
1158
  },
886
1159
  async execute(args) {
887
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
888
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(', ')
889
1170
  const data = await gql(key(), `
890
- query($teamId: String!) {
891
- cycles(first: ${limit}, filter: { team: { id: { eq: $teamId } } }) {
1171
+ ${varDecl} {
1172
+ cycles(first: ${limit}, filter: { team: { id: { eq: $teamId } } }${argList ? `, ${argList}` : ''}) {
892
1173
  nodes { ${CYCLE_FIELDS} }
1174
+ ${PAGE_INFO_FIELDS}
893
1175
  }
894
1176
  }
895
- `, { teamId })
1177
+ `, vars)
896
1178
  const cycles = data.cycles.nodes.map((c) => ({
897
1179
  id: c.id,
898
1180
  number: c.number,
@@ -901,7 +1183,7 @@ function registerLinearTools(ctx, config) {
901
1183
  endsAt: c.endsAt,
902
1184
  completedAt: c.completedAt,
903
1185
  }))
904
- return JSON.stringify({ team: args.team, count: cycles.length, cycles }, null, 2)
1186
+ return JSON.stringify({ team: args.team, count: cycles.length, cycles, pageInfo: normalizePageInfo(data.cycles.pageInfo) }, null, 2)
905
1187
  },
906
1188
  }),
907
1189
  )
@@ -936,9 +1218,10 @@ function registerLinearTools(ctx, config) {
936
1218
  register(
937
1219
  defineTool({
938
1220
  name: 'linear_user_list',
939
- description: 'List users in the workspace. Returns up to `limit` users (default 20, max 50).',
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 }.',
940
1222
  parameters: {
941
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' },
942
1225
  },
943
1226
  output: {
944
1227
  schema: { type: 'string' },
@@ -946,14 +1229,23 @@ function registerLinearTools(ctx, config) {
946
1229
  },
947
1230
  async execute(args) {
948
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(', ')
949
1240
  const data = await gql(key(), `
950
- {
951
- users(first: ${limit}) {
1241
+ ${varDecl} {
1242
+ users(first: ${limit}${argList ? `, ${argList}` : ''}) {
952
1243
  nodes { ${USER_FIELDS} }
1244
+ ${PAGE_INFO_FIELDS}
953
1245
  }
954
1246
  }
955
- `)
956
- return JSON.stringify({ count: data.users.nodes.length, users: data.users.nodes }, null, 2)
1247
+ `, vars)
1248
+ return JSON.stringify({ count: data.users.nodes.length, users: data.users.nodes, pageInfo: normalizePageInfo(data.users.pageInfo) }, null, 2)
957
1249
  },
958
1250
  }),
959
1251
  )
@@ -1085,693 +1377,50 @@ function registerLinearTools(ctx, config) {
1085
1377
  },
1086
1378
  }),
1087
1379
  )
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
1380
 
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 } : {}),
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' })
1765
1416
  }
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
- }
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
+ )
1775
1424
  }
1776
1425
 
1777
1426
  // ---------------------------------------------------------------------------
@@ -1782,108 +1431,30 @@ function createMcpDefinition(ctx, client, sdk, publicName, tool, config) {
1782
1431
  * Plugin config:
1783
1432
  * apiKey: Linear personal API key (li_api_...) for the GraphQL tools.
1784
1433
  * 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
1434
  *
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.
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.
1798
1438
  */
1799
- // Cordis 0.1.5 validates a plugin's `Config` as a Standard Schema (it calls
1439
+ // Cordis validates a plugin's `Config` as a Standard Schema (it calls
1800
1440
  // Config["~standard"].validate(...)). The real API key still comes from the
1801
1441
  // LINEAR_API_KEY env var (see resolveKey); this schema only needs to accept an
1802
1442
  // optional `apiKey` string so a patch row may also supply one. `.default('')`
1803
1443
  // makes a missing field valid; the empty value is ignored by resolveKey, which
1804
1444
  // 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
1445
  const Config = z.object({
1820
1446
  apiKey: z.string().default(''),
1821
- mcp: McpConfig,
1822
1447
  })
1823
1448
 
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) {
1449
+ function apply(ctx, config) {
1842
1450
  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
1451
  }
1861
1452
 
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.
1453
+ // Cordis's namespace unwrap keys off { name, inject, apply }; this module
1454
+ // exports exactly that plugin surface and nothing else.
1866
1455
  export {
1867
1456
  Config,
1868
1457
  apply,
1869
1458
  inject,
1870
1459
  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
1460
  }