@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/LICENSE +21 -0
- package/README.md +356 -2
- package/THIRD_PARTY_NOTICES.md +50 -0
- package/cordis.patch.yml +19 -0
- package/index.js +1889 -0
- package/package.json +42 -4
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
|
+
}
|