@kb1slnlabs/dsh-linear 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +258 -206
- package/THIRD_PARTY_NOTICES.md +12 -43
- package/index.js +472 -901
- package/package.json +4 -6
package/index.js
CHANGED
|
@@ -4,47 +4,58 @@
|
|
|
4
4
|
// export — a stray default collapses the module via the Loader's unwrapExports).
|
|
5
5
|
// `apply` registers model-facing tools through ctx.tools.register.
|
|
6
6
|
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
// `tools/list` and every call is forwarded with `tools/call` using the raw
|
|
14
|
-
// wire name. Credentials are never embedded in tool definitions or names.
|
|
7
|
+
// One tool surface:
|
|
8
|
+
// GraphQL tools (linear_*): 26 hand-written tools that talk to the Linear
|
|
9
|
+
// GraphQL API (https://api.linear.app/graphql) with a personal API token
|
|
10
|
+
// (env LINEAR_API_KEY or config apiKey). There is no MCP server and no MCP
|
|
11
|
+
// client here — no connection to mcp.linear.app is ever opened, and no MCP
|
|
12
|
+
// configuration exists. The 26 tools below are the entire surface.
|
|
15
13
|
//
|
|
16
|
-
// Peer deps (@deepseek-ai/dsh-tools, @deepseek-ai/cordis
|
|
14
|
+
// Peer deps (@deepseek-ai/dsh-tools, @deepseek-ai/cordis) resolve
|
|
17
15
|
// from the dsh install's node_modules — this file lives outside any pnpm tree,
|
|
18
16
|
// so a plain import would not find them; the resolvers below pin resolution to
|
|
19
17
|
// the dsh install root by absolute file:// URL.
|
|
20
18
|
//
|
|
21
19
|
// GraphQL tools:
|
|
22
|
-
// linear_issue_list
|
|
23
|
-
// linear_issue_get
|
|
24
|
-
// linear_issue_create
|
|
25
|
-
// linear_issue_update
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
|
|
36
|
-
|
|
20
|
+
// linear_issue_list — list issues (filter by team, state, search, limit, cursor pagination)
|
|
21
|
+
// linear_issue_get — one issue by id or identifier (e.g. "APT-123")
|
|
22
|
+
// linear_issue_create — create an issue in a team
|
|
23
|
+
// linear_issue_update — update an issue (state, description, assignee, labels, priority)
|
|
24
|
+
// linear_issue_archive — archive an issue (id or identifier)
|
|
25
|
+
// linear_issue_unarchive — unarchive a previously archived issue
|
|
26
|
+
// linear_comment_add — add a comment to an issue
|
|
27
|
+
// linear_comment_update — update a comment body (comment UUID)
|
|
28
|
+
// linear_comment_delete — delete a comment (comment UUID)
|
|
29
|
+
// linear_comment_list — list comments on an issue (cursor pagination)
|
|
30
|
+
// linear_relation_add — link a related issue to an issue
|
|
31
|
+
// linear_team_list — list teams
|
|
32
|
+
// linear_team_get — one team by id (UUID) or key
|
|
33
|
+
// linear_state_list — list workflow states for a team
|
|
34
|
+
// linear_label_list — list labels for a team
|
|
35
|
+
// linear_label_get — one label by id (UUID)
|
|
36
|
+
// linear_label_create — create a label for a team
|
|
37
|
+
// linear_project_list — list projects
|
|
38
|
+
// linear_project_get — one project by id (UUID)
|
|
39
|
+
// linear_project_create — create a project
|
|
40
|
+
// linear_project_update — update a project (name, description, content, dates, teams)
|
|
41
|
+
// linear_cycle_list — list cycles for a team
|
|
42
|
+
// linear_cycle_get — one cycle by id (UUID)
|
|
43
|
+
// linear_user_list — list users in the workspace
|
|
44
|
+
// linear_user_get — one user by id (UUID) or email
|
|
45
|
+
// linear_graphql — direct GraphQL escape hatch (caller's own query or
|
|
46
|
+
// mutation document; coverage is whatever the Linear
|
|
47
|
+
// API exposes to the key, not named-tool parity)
|
|
48
|
+
|
|
37
49
|
import { createRequire } from 'node:module'
|
|
38
50
|
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
39
51
|
import { homedir } from 'node:os'
|
|
40
52
|
import { dirname, join } from 'node:path'
|
|
41
|
-
import { isDeepStrictEqual } from 'node:util'
|
|
42
53
|
|
|
43
54
|
// The plugin source lives outside any pnpm tree, so a bare import('@deepseek-ai/dsh-tools')
|
|
44
55
|
// cannot resolve from here. Every dependency is therefore requested as a STANDARD bare
|
|
45
|
-
// specifier ('@deepseek-ai/dsh-tools', '@deepseek-ai/schemastery'
|
|
46
|
-
//
|
|
47
|
-
//
|
|
56
|
+
// specifier ('@deepseek-ai/dsh-tools', '@deepseek-ai/schemastery') and resolved through the
|
|
57
|
+
// packages' own "exports" maps — never through a hardcoded entry filename. Node's own resolver
|
|
58
|
+
// walks up from this file, so
|
|
48
59
|
// when the plugin is installed inside a tree that carries these packages (or one of their
|
|
49
60
|
// peer locations), plain resolution finds them with no anchoring at all. Only when that
|
|
50
61
|
// fails do we fall back to resolving from the dsh install root, derived portably from
|
|
@@ -120,46 +131,22 @@ function fallbackNodeModulesRoots() {
|
|
|
120
131
|
*
|
|
121
132
|
* Peer dependencies are declared as BOTH `dependencies` and `peerDependencies`, because
|
|
122
133
|
* either location may be what Node finds — a copy installed next to this plugin, or the host
|
|
123
|
-
* dsh install's copy.
|
|
124
|
-
* group below: for the MCP SDK that matters beyond version skew, since its zod-backed
|
|
125
|
-
* schemas are identity-sensitive. So if plain resolution provides one member of a group, all
|
|
126
|
-
* members are re-resolved from that same anchor and cached together; if plain resolution
|
|
127
|
-
* provides none of them, the first anchored tree that provides the whole group serves it.
|
|
134
|
+
* dsh install's copy.
|
|
128
135
|
*/
|
|
129
136
|
function resolveEntry(override, specifier) {
|
|
130
137
|
if (override) return override
|
|
131
|
-
|
|
132
|
-
if (!group) return tryResolveFrom(selfPath, specifier) ?? specifier
|
|
133
|
-
if (groupCache.has(specifier)) return groupCache.get(specifier)
|
|
134
|
-
// Anchors in preference order: this plugin's own tree first (that is what a plain bare
|
|
135
|
-
// import would get), then the dsh install, then each npm-global fallback root.
|
|
136
|
-
const anchors = [selfPath, dshInstallAnchor(), ...fallbackNodeModulesRoots().map((root) => join(root, 'noop.js'))].filter(Boolean)
|
|
137
|
-
for (const anchor of anchors) {
|
|
138
|
-
if (resolveGroup(anchor, group)) return groupCache.get(specifier)
|
|
139
|
-
}
|
|
140
|
-
// No tree provided the complete group: fall back to plain resolution per member.
|
|
141
|
-
return tryResolveFrom(selfPath, specifier) ?? specifier
|
|
138
|
+
return tryResolveFrom(selfPath, specifier) ?? tryResolveFrom(dshInstallAnchor(), specifier) ?? tryResolveFromFromRoots(specifier) ?? specifier
|
|
142
139
|
}
|
|
143
140
|
|
|
144
|
-
/**
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
* return true. A partial tree pins nothing — incomplete groups are no better than resolving
|
|
152
|
-
* each specifier independently.
|
|
153
|
-
*/
|
|
154
|
-
function resolveGroup(anchor, group) {
|
|
155
|
-
const resolved = group.map((member) => tryResolveFrom(anchor, member))
|
|
156
|
-
if (resolved.some((entry) => entry === undefined)) return false
|
|
157
|
-
for (let i = 0; i < group.length; i += 1) if (!groupCache.has(group[i])) groupCache.set(group[i], resolved[i])
|
|
158
|
-
return true
|
|
141
|
+
/** Last resort: the same bare specifier resolved from each npm-global fallback root. */
|
|
142
|
+
function tryResolveFromFromRoots(specifier) {
|
|
143
|
+
for (const root of fallbackNodeModulesRoots()) {
|
|
144
|
+
const entry = tryResolveFrom(join(root, 'noop.js'), specifier)
|
|
145
|
+
if (entry !== undefined) return entry
|
|
146
|
+
}
|
|
147
|
+
return undefined
|
|
159
148
|
}
|
|
160
149
|
|
|
161
|
-
const groupCache = new Map()
|
|
162
|
-
|
|
163
150
|
function resolveDshToolsEntry() {
|
|
164
151
|
return resolveEntry(process.env.DSH_LINEAR_TOOLS, '@deepseek-ai/dsh-tools')
|
|
165
152
|
}
|
|
@@ -169,35 +156,7 @@ function resolveDshSchemasteryEntry() {
|
|
|
169
156
|
return resolveEntry(process.env.DSH_LINEAR_SCHEMA, '@deepseek-ai/schemastery')
|
|
170
157
|
}
|
|
171
158
|
|
|
172
|
-
|
|
173
|
-
// @deepseek-ai/dsh-mcp-client bridge depends on, so we reuse the installed copy
|
|
174
|
-
// rather than inventing a transport. Subpaths come from the SDK's own "exports" map
|
|
175
|
-
// instead of a pinned dist path. Resolution is overridable for tests via
|
|
176
|
-
// DSH_LINEAR_MCP_SDK (client/index.js), DSH_LINEAR_MCP_SDK_TYPES (types.js),
|
|
177
|
-
// and DSH_LINEAR_MCP_SDK_TRANSPORT (client/streamableHttp.js).
|
|
178
|
-
// NOTE: in @modelcontextprotocol/sdk 1.x, StreamableHTTPClientTransport lives
|
|
179
|
-
// in client/streamableHttp.js, NOT client/index.js (which exports only Client)
|
|
180
|
-
// — dsh-mcp-client imports it from the same place.
|
|
181
|
-
function resolveMcpSdkClientEntry() {
|
|
182
|
-
return resolveEntry(process.env.DSH_LINEAR_MCP_SDK, '@modelcontextprotocol/sdk/client/index.js')
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
function resolveMcpSdkTransportEntry() {
|
|
186
|
-
return resolveEntry(process.env.DSH_LINEAR_MCP_SDK_TRANSPORT, '@modelcontextprotocol/sdk/client/streamableHttp.js')
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
function resolveMcpSdkTypesEntry() {
|
|
190
|
-
return resolveEntry(process.env.DSH_LINEAR_MCP_SDK_TYPES, '@modelcontextprotocol/sdk/types.js')
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
// The zod the SDK itself resolves to (`import * as z from 'zod/v4'`, hoisted
|
|
194
|
-
// next to it in the dsh install), so RawCallToolResultSchema is built with the
|
|
195
|
-
// same zod major as the SDK's schemas. DSH_LINEAR_MCP_SDK_ZOD overrides it.
|
|
196
|
-
function resolveMcpSdkZodEntry() {
|
|
197
|
-
return resolveEntry(process.env.DSH_LINEAR_MCP_SDK_ZOD, 'zod/v4')
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
const { defineTool, assertSupportedJsonSchema } = await import(resolveDshToolsEntry())
|
|
159
|
+
const { defineTool } = await import(resolveDshToolsEntry())
|
|
201
160
|
// schemastery's exports map serves the ESM entry to an import in a tree that provides it;
|
|
202
161
|
// resolved from the dsh install root the same specifier may land on its CJS entry, which
|
|
203
162
|
// Node wraps with both `default` and the named bindings. Take whichever shape is real.
|
|
@@ -251,6 +210,12 @@ const ISSUE_FIELDS = `
|
|
|
251
210
|
url
|
|
252
211
|
`
|
|
253
212
|
|
|
213
|
+
// Connection pagination block for list connections (issues, comments). Every
|
|
214
|
+
// direct list tool that supports `after` requests this alongside `nodes`.
|
|
215
|
+
const PAGE_INFO_FIELDS = `
|
|
216
|
+
pageInfo { hasNextPage endCursor }
|
|
217
|
+
`
|
|
218
|
+
|
|
254
219
|
const COMMENT_FIELDS = `
|
|
255
220
|
id body createdAt
|
|
256
221
|
`
|
|
@@ -288,6 +253,26 @@ const USER_FIELDS = `
|
|
|
288
253
|
|
|
289
254
|
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
|
290
255
|
|
|
256
|
+
// Cursor pagination (direct GraphQL list tools).
|
|
257
|
+
//
|
|
258
|
+
// `after` is an optional opaque cursor: it is ALWAYS sent as a GraphQL variable
|
|
259
|
+
// (`$after: String`), never interpolated into the query text, so a cursor can
|
|
260
|
+
// never alter the document. When no cursor is supplied the declaration and the
|
|
261
|
+
// `after:` argument are both omitted, which keeps the wire request byte-identical
|
|
262
|
+
// to the pre-pagination form (backwards compatibility). Every direct list tool
|
|
263
|
+
// uses this shape: linear_issue_list, linear_comment_list, linear_team_list,
|
|
264
|
+
// linear_state_list, linear_label_list, linear_project_list, linear_cycle_list
|
|
265
|
+
// and linear_user_list.
|
|
266
|
+
// The result shape mirrors GraphQL's PageInfo — null when the server did not
|
|
267
|
+
// report one, so an older/blank response never invents pagination state.
|
|
268
|
+
function normalizePageInfo(pageInfo) {
|
|
269
|
+
if (!pageInfo || typeof pageInfo !== 'object') return null
|
|
270
|
+
return {
|
|
271
|
+
hasNextPage: pageInfo.hasNextPage === true,
|
|
272
|
+
endCursor: typeof pageInfo.endCursor === 'string' ? pageInfo.endCursor : null,
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
291
276
|
// ---------------------------------------------------------------------------
|
|
292
277
|
// Entity resolution (resolve-then-mutate)
|
|
293
278
|
// ---------------------------------------------------------------------------
|
|
@@ -343,17 +328,60 @@ async function resolveStateId(key, teamId, stateName) {
|
|
|
343
328
|
return nodes[0].id
|
|
344
329
|
}
|
|
345
330
|
|
|
331
|
+
// Resolve a project name or a UUID to a Linear project UUID. The singular
|
|
332
|
+
// `project` field accepts ONLY a UUID id, so a name goes through the PLURAL
|
|
333
|
+
// `projects` field (which takes a ProjectFilter). Unlike teams, a non-UUID that
|
|
334
|
+
// simply matches nothing returns null here rather than throwing, so the caller
|
|
335
|
+
// renders the same `Project not found: <x>` shape the read tools use.
|
|
336
|
+
// More than one node is NOT silently shortened to the first one: an update aimed
|
|
337
|
+
// at an ambiguous name could modify the wrong project, so it throws and
|
|
338
|
+
// resolveLinearProjectId turns that into a clean { error } before any mutation.
|
|
339
|
+
async function findProjectId(key, ref) {
|
|
340
|
+
if (UUID_RE.test(ref)) return ref
|
|
341
|
+
const data = await gql(key, `
|
|
342
|
+
query($name: String!) {
|
|
343
|
+
projects(filter: { name: { eq: $name } }) {
|
|
344
|
+
nodes { id }
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
`, { name: ref })
|
|
348
|
+
const nodes = data.projects?.nodes ?? []
|
|
349
|
+
if (nodes.length > 1) {
|
|
350
|
+
throw new Error(`Project name '${ref}' is ambiguous (resolved ${nodes.length} projects via the projects filter); use the project id (UUID) instead`)
|
|
351
|
+
}
|
|
352
|
+
return nodes[0]?.id ?? null
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// findProjectId with its two failure shapes rendered for a tool: `null` means
|
|
356
|
+
// genuinely not found, and an ambiguity throw becomes its own clean error string.
|
|
357
|
+
async function resolveLinearProjectId(key, ref) {
|
|
358
|
+
try {
|
|
359
|
+
const projectId = await findProjectId(key, ref)
|
|
360
|
+
return { projectId }
|
|
361
|
+
} catch (err) {
|
|
362
|
+
if (/is ambiguous/.test(err?.message ?? '')) {
|
|
363
|
+
return { error: `Project name '${ref}' matched multiple projects — refine the name or use the project id (UUID)` }
|
|
364
|
+
}
|
|
365
|
+
throw err
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
346
369
|
// Resolve a human identifier ("APT-804") or a UUID to a Linear issue UUID.
|
|
347
370
|
// IssueFilter has no `identifier` field, so we split the identifier into
|
|
348
371
|
// team key + number and look the issue up by those, then return its id.
|
|
349
|
-
|
|
372
|
+
// `includeArchived` opts into IssuesQuery.includeArchived: an ARCHIVED issue is
|
|
373
|
+
// invisible to the plain issues query, so a tool that must reach one (only
|
|
374
|
+
// linear_issue_unarchive) passes it. Every other caller omits the option, which
|
|
375
|
+
// keeps the document byte-for-byte what it was before — no extra argument and
|
|
376
|
+
// no extra variable are sent when includeArchived is falsy.
|
|
377
|
+
async function findIssueId(key, id, { includeArchived = false } = {}) {
|
|
350
378
|
if (UUID_RE.test(id)) return id
|
|
351
379
|
const m = id.match(/^(.+?)-(\d+)$/)
|
|
352
380
|
if (!m) return null
|
|
353
381
|
const [, teamKey, num] = m
|
|
354
382
|
const data = await gql(key, `
|
|
355
383
|
query($team: String!, $num: Float!) {
|
|
356
|
-
issues(filter: { team: { key: { eq: $team } }, number: { eq: $num } }) {
|
|
384
|
+
issues(filter: { team: { key: { eq: $team } }, number: { eq: $num } }${includeArchived ? ', includeArchived: true' : ''}) {
|
|
357
385
|
nodes { id }
|
|
358
386
|
}
|
|
359
387
|
}
|
|
@@ -361,6 +389,41 @@ async function findIssueId(key, id) {
|
|
|
361
389
|
return data.issues.nodes[0]?.id ?? null
|
|
362
390
|
}
|
|
363
391
|
|
|
392
|
+
// Shared body for linear_issue_archive / linear_issue_unarchive. Both mutations
|
|
393
|
+
// take a bare `id` argument (there is no IssueArchiveInput type) and both return
|
|
394
|
+
// IssueArchivePayload { success entity lastSyncId } — the archived issue comes
|
|
395
|
+
// back under `entity`, NOT `issue` (verified by introspection). The identifier is
|
|
396
|
+
// resolved to a UUID first, so an unknown "APT-999" fails before any mutation.
|
|
397
|
+
// Unarchiving resolves the identifier with includeArchived: true, because an
|
|
398
|
+
// archived issue is exactly what the plain issues query hides — without it the
|
|
399
|
+
// tool could never unarchive anything it identified by name. Archiving keeps the
|
|
400
|
+
// ordinary lookup (an archived issue needs no second archive).
|
|
401
|
+
async function archiveIssue(key, id, unarchive) {
|
|
402
|
+
const issueId = await findIssueId(key, id, { includeArchived: unarchive })
|
|
403
|
+
if (!issueId) return JSON.stringify({ error: `Issue not found: ${id}` })
|
|
404
|
+
const mutation = unarchive ? 'issueUnarchive' : 'issueArchive'
|
|
405
|
+
const data = await gql(key, `
|
|
406
|
+
mutation($id: String!) {
|
|
407
|
+
${mutation}(id: $id) {
|
|
408
|
+
success entity { ${ISSUE_FIELDS} }
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
`, { id: issueId })
|
|
412
|
+
const payload = data[mutation]
|
|
413
|
+
if (!payload?.success) return JSON.stringify({ error: `${mutation} failed` })
|
|
414
|
+
const i = payload.entity
|
|
415
|
+
if (!i) return JSON.stringify({ ok: true, archived: !unarchive })
|
|
416
|
+
return JSON.stringify({
|
|
417
|
+
ok: true,
|
|
418
|
+
archived: !unarchive,
|
|
419
|
+
id: i.id,
|
|
420
|
+
identifier: i.identifier,
|
|
421
|
+
title: i.title,
|
|
422
|
+
state: i.state?.name,
|
|
423
|
+
url: i.url,
|
|
424
|
+
}, null, 2)
|
|
425
|
+
}
|
|
426
|
+
|
|
364
427
|
// ---------------------------------------------------------------------------
|
|
365
428
|
// GraphQL tools
|
|
366
429
|
// ---------------------------------------------------------------------------
|
|
@@ -392,12 +455,13 @@ function registerLinearTools(ctx, config) {
|
|
|
392
455
|
defineTool({
|
|
393
456
|
name: 'linear_issue_list',
|
|
394
457
|
description:
|
|
395
|
-
'List Linear issues. Optionally filter by team key (e.g. "APT"), state name, or free-text search. Returns up to `limit` most recent issues (default 20, max 50).',
|
|
458
|
+
'List Linear issues. Optionally filter by team key (e.g. "APT"), state name, or free-text search. Returns up to `limit` most recent issues (default 20, max 50). Cursor pagination: pass the previous page\'s pageInfo.endCursor as `after` to get the next page; the response includes pageInfo { hasNextPage, endCursor }.',
|
|
396
459
|
parameters: {
|
|
397
460
|
team: { type: 'string', description: 'Team key to filter by, e.g. "APT"' },
|
|
398
461
|
state: { type: 'string', description: 'State name to filter by, e.g. "Done", "In Progress"' },
|
|
399
462
|
search: { type: 'string', description: 'Free-text search across title/description' },
|
|
400
463
|
limit: { type: 'number', description: 'Max issues to return (default 20, max 50)' },
|
|
464
|
+
after: { type: 'string', description: 'Cursor from a previous page (pageInfo.endCursor) to fetch the next page' },
|
|
401
465
|
},
|
|
402
466
|
output: {
|
|
403
467
|
schema: { type: 'string' },
|
|
@@ -405,18 +469,25 @@ function registerLinearTools(ctx, config) {
|
|
|
405
469
|
},
|
|
406
470
|
async execute(args) {
|
|
407
471
|
const limit = Math.min(Math.max(1, Math.floor(args.limit ?? 20)), 50)
|
|
472
|
+
// Optional cursor. Kept as a variable (`$after`), never interpolated.
|
|
473
|
+
const after = typeof args.after === 'string' && args.after.trim() ? args.after : undefined
|
|
408
474
|
const filterParts = []
|
|
409
475
|
const vars = {}
|
|
410
476
|
if (args.team) { filterParts.push(`team: { key: { eq: $team } }`); vars.team = args.team }
|
|
411
477
|
if (args.state) { filterParts.push(`state: { name: { eq: $state } }`); vars.state = args.state }
|
|
412
478
|
if (args.search) { filterParts.push(`OR: { title: { containsInsensitive: $search }, description: { containsInsensitive: $search } }`); vars.search = args.search }
|
|
413
479
|
const filter = filterParts.length ? `filter: { ${filterParts.join(', ')} }` : ''
|
|
480
|
+
if (after !== undefined) vars.after = after
|
|
414
481
|
const usedVars = Object.keys(vars)
|
|
415
482
|
const varDecl = usedVars.length ? `query(${usedVars.map((v) => `$${v}: String`).join(', ')})` : 'query'
|
|
483
|
+
// One shared argument list: the cursor is declared only when it is also
|
|
484
|
+
// passed, so a call with no `after` sends exactly the pre-pagination document.
|
|
485
|
+
const argList = [filter, after !== undefined ? 'after: $after' : null].filter(Boolean).join(', ')
|
|
416
486
|
const data = await gql(key(), `
|
|
417
487
|
${varDecl} {
|
|
418
|
-
issues(first: ${limit}
|
|
488
|
+
issues(first: ${limit}${argList ? `, ${argList}` : ''}) {
|
|
419
489
|
nodes { ${ISSUE_FIELDS} }
|
|
490
|
+
${PAGE_INFO_FIELDS}
|
|
420
491
|
}
|
|
421
492
|
}
|
|
422
493
|
`, vars)
|
|
@@ -431,7 +502,7 @@ function registerLinearTools(ctx, config) {
|
|
|
431
502
|
labels: i.labels.nodes.map((l) => l.name),
|
|
432
503
|
url: i.url,
|
|
433
504
|
}))
|
|
434
|
-
return JSON.stringify({ count: issues.length, issues }, null, 2)
|
|
505
|
+
return JSON.stringify({ count: issues.length, issues, pageInfo: normalizePageInfo(data.issues.pageInfo) }, null, 2)
|
|
435
506
|
},
|
|
436
507
|
}),
|
|
437
508
|
)
|
|
@@ -590,6 +661,41 @@ function registerLinearTools(ctx, config) {
|
|
|
590
661
|
}),
|
|
591
662
|
)
|
|
592
663
|
|
|
664
|
+
register(
|
|
665
|
+
defineTool({
|
|
666
|
+
name: 'linear_issue_archive',
|
|
667
|
+
description:
|
|
668
|
+
'Archive a Linear issue by id (UUID) or identifier. Archived issues leave the team\'s default views. To bring an archived issue back, use the separate linear_issue_unarchive tool — this tool takes no unarchive argument.',
|
|
669
|
+
parameters: {
|
|
670
|
+
id: { type: 'string', required: true, description: 'Issue id (UUID) or identifier' },
|
|
671
|
+
},
|
|
672
|
+
output: {
|
|
673
|
+
schema: { type: 'string' },
|
|
674
|
+
render: (_a, v) => [{ type: 'text', text: v }],
|
|
675
|
+
},
|
|
676
|
+
async execute(args) {
|
|
677
|
+
return archiveIssue(key(), args.id, false)
|
|
678
|
+
},
|
|
679
|
+
}),
|
|
680
|
+
)
|
|
681
|
+
|
|
682
|
+
register(
|
|
683
|
+
defineTool({
|
|
684
|
+
name: 'linear_issue_unarchive',
|
|
685
|
+
description: 'Unarchive a previously archived Linear issue by id (UUID) or identifier, returning it to the team\'s views.',
|
|
686
|
+
parameters: {
|
|
687
|
+
id: { type: 'string', required: true, description: 'Issue id (UUID) or identifier' },
|
|
688
|
+
},
|
|
689
|
+
output: {
|
|
690
|
+
schema: { type: 'string' },
|
|
691
|
+
render: (_a, v) => [{ type: 'text', text: v }],
|
|
692
|
+
},
|
|
693
|
+
async execute(args) {
|
|
694
|
+
return archiveIssue(key(), args.id, true)
|
|
695
|
+
},
|
|
696
|
+
}),
|
|
697
|
+
)
|
|
698
|
+
|
|
593
699
|
// -- Comments ---------------------------------------------------------------
|
|
594
700
|
|
|
595
701
|
register(
|
|
@@ -623,10 +729,11 @@ function registerLinearTools(ctx, config) {
|
|
|
623
729
|
register(
|
|
624
730
|
defineTool({
|
|
625
731
|
name: 'linear_comment_list',
|
|
626
|
-
description: 'List comments on a Linear issue (by id or identifier), newest first.',
|
|
732
|
+
description: 'List comments on a Linear issue (by id or identifier), newest first. Cursor pagination: pass the previous page\'s pageInfo.endCursor as `after` to get the next page; the response includes pageInfo { hasNextPage, endCursor }.',
|
|
627
733
|
parameters: {
|
|
628
734
|
issue: { type: 'string', required: true, description: 'Issue id or identifier' },
|
|
629
735
|
limit: { type: 'number', description: 'Max comments (default 20, max 50)' },
|
|
736
|
+
after: { type: 'string', description: 'Cursor from a previous page (pageInfo.endCursor) to fetch the next page' },
|
|
630
737
|
},
|
|
631
738
|
output: {
|
|
632
739
|
schema: { type: 'string' },
|
|
@@ -634,28 +741,95 @@ function registerLinearTools(ctx, config) {
|
|
|
634
741
|
},
|
|
635
742
|
async execute(args) {
|
|
636
743
|
const limit = Math.min(Math.max(1, Math.floor(args.limit ?? 20)), 50)
|
|
744
|
+
// Optional cursor. Kept as a variable (`$after`), never interpolated.
|
|
745
|
+
const after = typeof args.after === 'string' && args.after.trim() ? args.after : undefined
|
|
637
746
|
const issueId = await findIssueId(key(), args.issue)
|
|
638
747
|
if (!issueId) return JSON.stringify({ error: `Issue not found: ${args.issue}` })
|
|
748
|
+
const vars = { id: issueId }
|
|
749
|
+
if (after !== undefined) vars.after = after
|
|
750
|
+
const varDecl = after !== undefined ? 'query($id: String!, $after: String)' : 'query($id: String!)'
|
|
751
|
+
// One shared argument list: the cursor is declared only when it is also
|
|
752
|
+
// passed, so a call with no `after` sends exactly the pre-pagination document.
|
|
753
|
+
const argList = after !== undefined ? 'after: $after' : ''
|
|
639
754
|
const data = await gql(key(), `
|
|
640
|
-
|
|
755
|
+
${varDecl} {
|
|
641
756
|
issue: issue(id: $id) {
|
|
642
757
|
id
|
|
643
|
-
comments(first: ${limit}) {
|
|
758
|
+
comments(first: ${limit}${argList ? `, ${argList}` : ''}) {
|
|
644
759
|
nodes { ${COMMENT_FIELDS} }
|
|
760
|
+
${PAGE_INFO_FIELDS}
|
|
645
761
|
}
|
|
646
762
|
}
|
|
647
763
|
}
|
|
648
|
-
`,
|
|
764
|
+
`, vars)
|
|
649
765
|
if (!data.issue) return JSON.stringify({ error: `Issue not found: ${args.issue}` })
|
|
650
766
|
return JSON.stringify({
|
|
651
767
|
issue: args.issue,
|
|
652
768
|
count: data.issue.comments.nodes.length,
|
|
653
769
|
comments: data.issue.comments.nodes,
|
|
770
|
+
pageInfo: normalizePageInfo(data.issue.comments.pageInfo),
|
|
654
771
|
}, null, 2)
|
|
655
772
|
},
|
|
656
773
|
}),
|
|
657
774
|
)
|
|
658
775
|
|
|
776
|
+
register(
|
|
777
|
+
defineTool({
|
|
778
|
+
name: 'linear_comment_update',
|
|
779
|
+
description: 'Update the body of an existing comment on a Linear issue. `id` is the comment UUID (from linear_comment_list); `body` is the new markdown body.',
|
|
780
|
+
parameters: {
|
|
781
|
+
id: { type: 'string', required: true, description: 'Comment id (UUID)' },
|
|
782
|
+
body: { type: 'string', required: true, description: 'New comment body (markdown)' },
|
|
783
|
+
},
|
|
784
|
+
output: {
|
|
785
|
+
schema: { type: 'string' },
|
|
786
|
+
render: (_a, v) => [{ type: 'text', text: v }],
|
|
787
|
+
},
|
|
788
|
+
async execute(args) {
|
|
789
|
+
// CommentUpdateInput carries only the changed fields; the comment UUID is
|
|
790
|
+
// a top-level `id` argument on the mutation (verified by introspection).
|
|
791
|
+
const data = await gql(key(), `
|
|
792
|
+
mutation($id: String!, $input: CommentUpdateInput!) {
|
|
793
|
+
commentUpdate(id: $id, input: $input) {
|
|
794
|
+
success comment { ${COMMENT_FIELDS} }
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
`, { id: args.id, input: { body: args.body } })
|
|
798
|
+
if (!data.commentUpdate.success) return JSON.stringify({ error: 'commentUpdate failed' })
|
|
799
|
+
const c = data.commentUpdate.comment
|
|
800
|
+
return JSON.stringify({ id: c?.id, body: c?.body }, null, 2)
|
|
801
|
+
},
|
|
802
|
+
}),
|
|
803
|
+
)
|
|
804
|
+
|
|
805
|
+
register(
|
|
806
|
+
defineTool({
|
|
807
|
+
name: 'linear_comment_delete',
|
|
808
|
+
description: 'Delete a comment from a Linear issue by its comment id (UUID, from linear_comment_list). This is destructive and cannot be undone.',
|
|
809
|
+
parameters: {
|
|
810
|
+
id: { type: 'string', required: true, description: 'Comment id (UUID)' },
|
|
811
|
+
},
|
|
812
|
+
output: {
|
|
813
|
+
schema: { type: 'string' },
|
|
814
|
+
render: (_a, v) => [{ type: 'text', text: v }],
|
|
815
|
+
},
|
|
816
|
+
async execute(args) {
|
|
817
|
+
// commentDelete takes a bare `id` argument — there is NO CommentDeleteInput
|
|
818
|
+
// type in the Linear schema (verified by introspection). Its payload is
|
|
819
|
+
// DeletePayload { success entityId lastSyncId } — no entity object.
|
|
820
|
+
const data = await gql(key(), `
|
|
821
|
+
mutation($id: String!) {
|
|
822
|
+
commentDelete(id: $id) {
|
|
823
|
+
success entityId
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
`, { id: args.id })
|
|
827
|
+
if (!data.commentDelete.success) return JSON.stringify({ error: 'commentDelete failed' })
|
|
828
|
+
return JSON.stringify({ ok: true, deletedId: data.commentDelete.entityId ?? args.id }, null, 2)
|
|
829
|
+
},
|
|
830
|
+
}),
|
|
831
|
+
)
|
|
832
|
+
|
|
659
833
|
// -- Relations --------------------------------------------------------------
|
|
660
834
|
|
|
661
835
|
register(
|
|
@@ -702,21 +876,32 @@ function registerLinearTools(ctx, config) {
|
|
|
702
876
|
register(
|
|
703
877
|
defineTool({
|
|
704
878
|
name: 'linear_team_list',
|
|
705
|
-
description: 'List all Linear teams (key, name, description).',
|
|
706
|
-
parameters: {
|
|
879
|
+
description: 'List all Linear teams (key, name, description). Cursor pagination: pass the previous page\'s pageInfo.endCursor as `after` to get the next page; the response includes pageInfo { hasNextPage, endCursor }.',
|
|
880
|
+
parameters: {
|
|
881
|
+
after: { type: 'string', description: 'Cursor from a previous page (pageInfo.endCursor) to fetch the next page' },
|
|
882
|
+
},
|
|
707
883
|
output: {
|
|
708
884
|
schema: { type: 'string' },
|
|
709
885
|
render: (_a, v) => [{ type: 'text', text: v }],
|
|
710
886
|
},
|
|
711
|
-
async execute() {
|
|
887
|
+
async execute(args) {
|
|
888
|
+
// Optional cursor. Kept as a variable (`$after`), never interpolated.
|
|
889
|
+
const after = typeof args.after === 'string' && args.after.trim() ? args.after : undefined
|
|
890
|
+
const vars = {}
|
|
891
|
+
if (after !== undefined) vars.after = after
|
|
892
|
+
const varDecl = after !== undefined ? 'query($after: String)' : 'query'
|
|
893
|
+
// One shared argument list: the cursor is declared only when it is also
|
|
894
|
+
// passed, so a call with no `after` sends exactly the pre-pagination document.
|
|
895
|
+
const argList = [after !== undefined ? 'after: $after' : null].filter(Boolean).join(', ')
|
|
712
896
|
const data = await gql(key(), `
|
|
713
|
-
{
|
|
714
|
-
teams(first: 100) {
|
|
897
|
+
${varDecl} {
|
|
898
|
+
teams(first: 100${argList ? `, ${argList}` : ''}) {
|
|
715
899
|
nodes { ${TEAM_FIELDS} }
|
|
900
|
+
${PAGE_INFO_FIELDS}
|
|
716
901
|
}
|
|
717
902
|
}
|
|
718
|
-
|
|
719
|
-
return JSON.stringify({ count: data.teams.nodes.length, teams: data.teams.nodes }, null, 2)
|
|
903
|
+
`, vars)
|
|
904
|
+
return JSON.stringify({ count: data.teams.nodes.length, teams: data.teams.nodes, pageInfo: normalizePageInfo(data.teams.pageInfo) }, null, 2)
|
|
720
905
|
},
|
|
721
906
|
}),
|
|
722
907
|
)
|
|
@@ -724,27 +909,37 @@ function registerLinearTools(ctx, config) {
|
|
|
724
909
|
register(
|
|
725
910
|
defineTool({
|
|
726
911
|
name: 'linear_state_list',
|
|
727
|
-
description: 'List workflow states for a team (e.g. Todo, In Progress, Done).',
|
|
912
|
+
description: 'List workflow states for a team (e.g. Todo, In Progress, Done). Cursor pagination: pass the previous page\'s pageInfo.endCursor as `after` to get the next page; the response includes pageInfo { hasNextPage, endCursor }.',
|
|
728
913
|
parameters: {
|
|
729
914
|
team: { type: 'string', required: true, description: 'Team key, e.g. "APT"' },
|
|
915
|
+
after: { type: 'string', description: 'Cursor from a previous page (pageInfo.endCursor) to fetch the next page' },
|
|
730
916
|
},
|
|
731
917
|
output: {
|
|
732
918
|
schema: { type: 'string' },
|
|
733
919
|
render: (_a, v) => [{ type: 'text', text: v }],
|
|
734
920
|
},
|
|
735
921
|
async execute(args) {
|
|
922
|
+
// Optional cursor. Kept as a variable (`$after`), never interpolated.
|
|
923
|
+
const after = typeof args.after === 'string' && args.after.trim() ? args.after : undefined
|
|
736
924
|
// Resolve the team to a UUID via the PLURAL `teams` field (the singular
|
|
737
925
|
// `team` field takes only a UUID id), then list that team's states via
|
|
738
926
|
// the top-level `workflowStates` field filtered by team id.
|
|
739
927
|
const teamId = await resolveTeamId(key(), args.team)
|
|
928
|
+
const vars = { teamId }
|
|
929
|
+
if (after !== undefined) vars.after = after
|
|
930
|
+
const varDecl = after !== undefined ? 'query($teamId: String!, $after: String)' : 'query($teamId: String!)'
|
|
931
|
+
// One shared argument list: the cursor is declared only when it is also
|
|
932
|
+
// passed, so a call with no `after` sends exactly the pre-pagination document.
|
|
933
|
+
const argList = [after !== undefined ? 'after: $after' : null].filter(Boolean).join(', ')
|
|
740
934
|
const data = await gql(key(), `
|
|
741
|
-
|
|
742
|
-
workflowStates(filter: { team: { id: { eq: $teamId } } }) {
|
|
935
|
+
${varDecl} {
|
|
936
|
+
workflowStates(filter: { team: { id: { eq: $teamId } } }${argList ? `, ${argList}` : ''}) {
|
|
743
937
|
nodes { ${STATE_FIELDS} }
|
|
938
|
+
${PAGE_INFO_FIELDS}
|
|
744
939
|
}
|
|
745
940
|
}
|
|
746
|
-
`,
|
|
747
|
-
return JSON.stringify({ team: args.team, states: data.workflowStates.nodes }, null, 2)
|
|
941
|
+
`, vars)
|
|
942
|
+
return JSON.stringify({ team: args.team, states: data.workflowStates.nodes, pageInfo: normalizePageInfo(data.workflowStates.pageInfo) }, null, 2)
|
|
748
943
|
},
|
|
749
944
|
}),
|
|
750
945
|
)
|
|
@@ -752,24 +947,34 @@ function registerLinearTools(ctx, config) {
|
|
|
752
947
|
register(
|
|
753
948
|
defineTool({
|
|
754
949
|
name: 'linear_label_list',
|
|
755
|
-
description: 'List labels for a team.',
|
|
950
|
+
description: 'List labels for a team. Cursor pagination: pass the previous page\'s pageInfo.endCursor as `after` to get the next page; the response includes pageInfo { hasNextPage, endCursor }.',
|
|
756
951
|
parameters: {
|
|
757
952
|
team: { type: 'string', required: true, description: 'Team key, e.g. "APT"' },
|
|
953
|
+
after: { type: 'string', description: 'Cursor from a previous page (pageInfo.endCursor) to fetch the next page' },
|
|
758
954
|
},
|
|
759
955
|
output: {
|
|
760
956
|
schema: { type: 'string' },
|
|
761
957
|
render: (_a, v) => [{ type: 'text', text: v }],
|
|
762
958
|
},
|
|
763
959
|
async execute(args) {
|
|
960
|
+
// Optional cursor. Kept as a variable (`$after`), never interpolated.
|
|
961
|
+
const after = typeof args.after === 'string' && args.after.trim() ? args.after : undefined
|
|
764
962
|
const teamId = await resolveTeamId(key(), args.team)
|
|
963
|
+
const vars = { teamId }
|
|
964
|
+
if (after !== undefined) vars.after = after
|
|
965
|
+
const varDecl = after !== undefined ? 'query($teamId: String!, $after: String)' : 'query($teamId: String!)'
|
|
966
|
+
// One shared argument list: the cursor is declared only when it is also
|
|
967
|
+
// passed, so a call with no `after` sends exactly the pre-pagination document.
|
|
968
|
+
const argList = [after !== undefined ? 'after: $after' : null].filter(Boolean).join(', ')
|
|
765
969
|
const data = await gql(key(), `
|
|
766
|
-
|
|
767
|
-
issueLabels(filter: { team: { id: { eq: $teamId } } }) {
|
|
970
|
+
${varDecl} {
|
|
971
|
+
issueLabels(filter: { team: { id: { eq: $teamId } } }${argList ? `, ${argList}` : ''}) {
|
|
768
972
|
nodes { ${LABEL_FIELDS} }
|
|
973
|
+
${PAGE_INFO_FIELDS}
|
|
769
974
|
}
|
|
770
975
|
}
|
|
771
|
-
`,
|
|
772
|
-
return JSON.stringify({ team: args.team, labels: data.issueLabels.nodes }, null, 2)
|
|
976
|
+
`, vars)
|
|
977
|
+
return JSON.stringify({ team: args.team, labels: data.issueLabels.nodes, pageInfo: normalizePageInfo(data.issueLabels.pageInfo) }, null, 2)
|
|
773
978
|
},
|
|
774
979
|
}),
|
|
775
980
|
)
|
|
@@ -777,9 +982,10 @@ function registerLinearTools(ctx, config) {
|
|
|
777
982
|
register(
|
|
778
983
|
defineTool({
|
|
779
984
|
name: 'linear_project_list',
|
|
780
|
-
description: 'List Linear projects (name, description, state, teams).',
|
|
985
|
+
description: 'List Linear projects (name, description, state, teams). Cursor pagination: pass the previous page\'s pageInfo.endCursor as `after` to get the next page; the response includes pageInfo { hasNextPage, endCursor }.',
|
|
781
986
|
parameters: {
|
|
782
987
|
limit: { type: 'number', description: 'Max projects (default 20, max 50)' },
|
|
988
|
+
after: { type: 'string', description: 'Cursor from a previous page (pageInfo.endCursor) to fetch the next page' },
|
|
783
989
|
},
|
|
784
990
|
output: {
|
|
785
991
|
schema: { type: 'string' },
|
|
@@ -787,14 +993,23 @@ function registerLinearTools(ctx, config) {
|
|
|
787
993
|
},
|
|
788
994
|
async execute(args) {
|
|
789
995
|
const limit = Math.min(Math.max(1, Math.floor(args.limit ?? 20)), 50)
|
|
996
|
+
// Optional cursor. Kept as a variable (`$after`), never interpolated.
|
|
997
|
+
const after = typeof args.after === 'string' && args.after.trim() ? args.after : undefined
|
|
998
|
+
const vars = {}
|
|
999
|
+
if (after !== undefined) vars.after = after
|
|
1000
|
+
const varDecl = after !== undefined ? 'query($after: String)' : 'query'
|
|
1001
|
+
// One shared argument list: the cursor is declared only when it is also
|
|
1002
|
+
// passed, so a call with no `after` sends exactly the pre-pagination document.
|
|
1003
|
+
const argList = [after !== undefined ? 'after: $after' : null].filter(Boolean).join(', ')
|
|
790
1004
|
const data = await gql(key(), `
|
|
791
|
-
{
|
|
792
|
-
projects(first: ${limit}) {
|
|
1005
|
+
${varDecl} {
|
|
1006
|
+
projects(first: ${limit}${argList ? `, ${argList}` : ''}) {
|
|
793
1007
|
nodes { ${PROJECT_FIELDS} }
|
|
1008
|
+
${PAGE_INFO_FIELDS}
|
|
794
1009
|
}
|
|
795
1010
|
}
|
|
796
|
-
|
|
797
|
-
return JSON.stringify({ count: data.projects.nodes.length, projects: data.projects.nodes }, null, 2)
|
|
1011
|
+
`, vars)
|
|
1012
|
+
return JSON.stringify({ count: data.projects.nodes.length, projects: data.projects.nodes, pageInfo: normalizePageInfo(data.projects.pageInfo) }, null, 2)
|
|
798
1013
|
},
|
|
799
1014
|
}),
|
|
800
1015
|
)
|
|
@@ -832,6 +1047,63 @@ function registerLinearTools(ctx, config) {
|
|
|
832
1047
|
}),
|
|
833
1048
|
)
|
|
834
1049
|
|
|
1050
|
+
register(
|
|
1051
|
+
defineTool({
|
|
1052
|
+
name: 'linear_project_update',
|
|
1053
|
+
description:
|
|
1054
|
+
'Update a Linear project by id (UUID) or exact name. Any of: name, description, content, icon, color, team keys, start date, target date, priority.',
|
|
1055
|
+
parameters: {
|
|
1056
|
+
id: { type: 'string', required: true, description: 'Project id (UUID) or exact project name' },
|
|
1057
|
+
name: { type: 'string', description: 'New project name' },
|
|
1058
|
+
description: { type: 'string', description: 'New project description' },
|
|
1059
|
+
content: { type: 'string', description: 'New project content (markdown)' },
|
|
1060
|
+
icon: { type: 'string', description: 'Project icon identifier, e.g. "Rocket"' },
|
|
1061
|
+
color: { type: 'string', description: 'Hex color, e.g. "#ff0000"' },
|
|
1062
|
+
teams: { type: 'array', items: { type: 'string' }, description: 'Team keys to set (replaces existing)' },
|
|
1063
|
+
startDate: { type: 'string', description: 'Start date, YYYY-MM-DD' },
|
|
1064
|
+
targetDate: { type: 'string', description: 'Target date, YYYY-MM-DD' },
|
|
1065
|
+
priority: { type: 'number', description: 'Priority 0 (none) to 4 (urgent)' },
|
|
1066
|
+
},
|
|
1067
|
+
output: {
|
|
1068
|
+
schema: { type: 'string' },
|
|
1069
|
+
render: (_a, v) => [{ type: 'text', text: v }],
|
|
1070
|
+
},
|
|
1071
|
+
async execute(args) {
|
|
1072
|
+
// A name that matches more than one project never reaches the mutation —
|
|
1073
|
+
// resolveLinearProjectId answers with a clean ambiguity error instead.
|
|
1074
|
+
const resolved = await resolveLinearProjectId(key(), args.id)
|
|
1075
|
+
if (resolved.error) return JSON.stringify({ error: resolved.error })
|
|
1076
|
+
const projectId = resolved.projectId
|
|
1077
|
+
if (!projectId) return JSON.stringify({ error: `Project not found: ${args.id}` })
|
|
1078
|
+
|
|
1079
|
+
// ProjectUpdateInput is flat (teamIds are UUIDs), and the mutation takes
|
|
1080
|
+
// the project UUID as a top-level `id` argument alongside `input`.
|
|
1081
|
+
const input = {}
|
|
1082
|
+
if (args.name !== undefined) input.name = args.name
|
|
1083
|
+
if (args.description !== undefined) input.description = args.description
|
|
1084
|
+
if (args.content !== undefined) input.content = args.content
|
|
1085
|
+
if (args.icon !== undefined) input.icon = args.icon
|
|
1086
|
+
if (args.color !== undefined) input.color = args.color
|
|
1087
|
+
if (args.startDate !== undefined) input.startDate = args.startDate
|
|
1088
|
+
if (args.targetDate !== undefined) input.targetDate = args.targetDate
|
|
1089
|
+
if (args.priority !== undefined) input.priority = args.priority
|
|
1090
|
+
if (args.teams !== undefined) {
|
|
1091
|
+
input.teamIds = await Promise.all((args.teams ?? []).map((t) => resolveTeamId(key(), t)))
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
const data = await gql(key(), `
|
|
1095
|
+
mutation($id: String!, $input: ProjectUpdateInput!) {
|
|
1096
|
+
projectUpdate(id: $id, input: $input) {
|
|
1097
|
+
success project { ${PROJECT_FIELDS} }
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
`, { id: projectId, input })
|
|
1101
|
+
if (!data.projectUpdate.success) return JSON.stringify({ error: 'projectUpdate failed' })
|
|
1102
|
+
return JSON.stringify(data.projectUpdate.project, null, 2)
|
|
1103
|
+
},
|
|
1104
|
+
}),
|
|
1105
|
+
)
|
|
1106
|
+
|
|
835
1107
|
register(
|
|
836
1108
|
defineTool({
|
|
837
1109
|
name: 'linear_label_create',
|
|
@@ -874,10 +1146,11 @@ function registerLinearTools(ctx, config) {
|
|
|
874
1146
|
register(
|
|
875
1147
|
defineTool({
|
|
876
1148
|
name: 'linear_cycle_list',
|
|
877
|
-
description: 'List cycles for a team. Returns up to `limit` most recent cycles (default 20, max 50).',
|
|
1149
|
+
description: 'List cycles for a team. Returns up to `limit` most recent cycles (default 20, max 50). Cursor pagination: pass the previous page\'s pageInfo.endCursor as `after` to get the next page; the response includes pageInfo { hasNextPage, endCursor }.',
|
|
878
1150
|
parameters: {
|
|
879
1151
|
team: { type: 'string', required: true, description: 'Team key, e.g. "APT"' },
|
|
880
1152
|
limit: { type: 'number', description: 'Max cycles (default 20, max 50)' },
|
|
1153
|
+
after: { type: 'string', description: 'Cursor from a previous page (pageInfo.endCursor) to fetch the next page' },
|
|
881
1154
|
},
|
|
882
1155
|
output: {
|
|
883
1156
|
schema: { type: 'string' },
|
|
@@ -885,14 +1158,23 @@ function registerLinearTools(ctx, config) {
|
|
|
885
1158
|
},
|
|
886
1159
|
async execute(args) {
|
|
887
1160
|
const limit = Math.min(Math.max(1, Math.floor(args.limit ?? 20)), 50)
|
|
1161
|
+
// Optional cursor. Kept as a variable (`$after`), never interpolated.
|
|
1162
|
+
const after = typeof args.after === 'string' && args.after.trim() ? args.after : undefined
|
|
888
1163
|
const teamId = await resolveTeamId(key(), args.team)
|
|
1164
|
+
const vars = { teamId }
|
|
1165
|
+
if (after !== undefined) vars.after = after
|
|
1166
|
+
const varDecl = after !== undefined ? 'query($teamId: String!, $after: String)' : 'query($teamId: String!)'
|
|
1167
|
+
// One shared argument list: the cursor is declared only when it is also
|
|
1168
|
+
// passed, so a call with no `after` sends exactly the pre-pagination document.
|
|
1169
|
+
const argList = [after !== undefined ? 'after: $after' : null].filter(Boolean).join(', ')
|
|
889
1170
|
const data = await gql(key(), `
|
|
890
|
-
|
|
891
|
-
cycles(first: ${limit}, filter: { team: { id: { eq: $teamId } } }) {
|
|
1171
|
+
${varDecl} {
|
|
1172
|
+
cycles(first: ${limit}, filter: { team: { id: { eq: $teamId } } }${argList ? `, ${argList}` : ''}) {
|
|
892
1173
|
nodes { ${CYCLE_FIELDS} }
|
|
1174
|
+
${PAGE_INFO_FIELDS}
|
|
893
1175
|
}
|
|
894
1176
|
}
|
|
895
|
-
`,
|
|
1177
|
+
`, vars)
|
|
896
1178
|
const cycles = data.cycles.nodes.map((c) => ({
|
|
897
1179
|
id: c.id,
|
|
898
1180
|
number: c.number,
|
|
@@ -901,7 +1183,7 @@ function registerLinearTools(ctx, config) {
|
|
|
901
1183
|
endsAt: c.endsAt,
|
|
902
1184
|
completedAt: c.completedAt,
|
|
903
1185
|
}))
|
|
904
|
-
return JSON.stringify({ team: args.team, count: cycles.length, cycles }, null, 2)
|
|
1186
|
+
return JSON.stringify({ team: args.team, count: cycles.length, cycles, pageInfo: normalizePageInfo(data.cycles.pageInfo) }, null, 2)
|
|
905
1187
|
},
|
|
906
1188
|
}),
|
|
907
1189
|
)
|
|
@@ -936,9 +1218,10 @@ function registerLinearTools(ctx, config) {
|
|
|
936
1218
|
register(
|
|
937
1219
|
defineTool({
|
|
938
1220
|
name: 'linear_user_list',
|
|
939
|
-
description: 'List users in the workspace. Returns up to `limit` users (default 20, max 50).',
|
|
1221
|
+
description: 'List users in the workspace. Returns up to `limit` users (default 20, max 50). Cursor pagination: pass the previous page\'s pageInfo.endCursor as `after` to get the next page; the response includes pageInfo { hasNextPage, endCursor }.',
|
|
940
1222
|
parameters: {
|
|
941
1223
|
limit: { type: 'number', description: 'Max users (default 20, max 50)' },
|
|
1224
|
+
after: { type: 'string', description: 'Cursor from a previous page (pageInfo.endCursor) to fetch the next page' },
|
|
942
1225
|
},
|
|
943
1226
|
output: {
|
|
944
1227
|
schema: { type: 'string' },
|
|
@@ -946,14 +1229,23 @@ function registerLinearTools(ctx, config) {
|
|
|
946
1229
|
},
|
|
947
1230
|
async execute(args) {
|
|
948
1231
|
const limit = Math.min(Math.max(1, Math.floor(args.limit ?? 20)), 50)
|
|
1232
|
+
// Optional cursor. Kept as a variable (`$after`), never interpolated.
|
|
1233
|
+
const after = typeof args.after === 'string' && args.after.trim() ? args.after : undefined
|
|
1234
|
+
const vars = {}
|
|
1235
|
+
if (after !== undefined) vars.after = after
|
|
1236
|
+
const varDecl = after !== undefined ? 'query($after: String)' : 'query'
|
|
1237
|
+
// One shared argument list: the cursor is declared only when it is also
|
|
1238
|
+
// passed, so a call with no `after` sends exactly the pre-pagination document.
|
|
1239
|
+
const argList = [after !== undefined ? 'after: $after' : null].filter(Boolean).join(', ')
|
|
949
1240
|
const data = await gql(key(), `
|
|
950
|
-
{
|
|
951
|
-
users(first: ${limit}) {
|
|
1241
|
+
${varDecl} {
|
|
1242
|
+
users(first: ${limit}${argList ? `, ${argList}` : ''}) {
|
|
952
1243
|
nodes { ${USER_FIELDS} }
|
|
1244
|
+
${PAGE_INFO_FIELDS}
|
|
953
1245
|
}
|
|
954
1246
|
}
|
|
955
|
-
|
|
956
|
-
return JSON.stringify({ count: data.users.nodes.length, users: data.users.nodes }, null, 2)
|
|
1247
|
+
`, vars)
|
|
1248
|
+
return JSON.stringify({ count: data.users.nodes.length, users: data.users.nodes, pageInfo: normalizePageInfo(data.users.pageInfo) }, null, 2)
|
|
957
1249
|
},
|
|
958
1250
|
}),
|
|
959
1251
|
)
|
|
@@ -1085,693 +1377,50 @@ function registerLinearTools(ctx, config) {
|
|
|
1085
1377
|
},
|
|
1086
1378
|
}),
|
|
1087
1379
|
)
|
|
1088
|
-
}
|
|
1089
|
-
|
|
1090
|
-
// ---------------------------------------------------------------------------
|
|
1091
|
-
// MCP bridge — parity with Linear's hosted MCP server
|
|
1092
|
-
// ---------------------------------------------------------------------------
|
|
1093
|
-
//
|
|
1094
|
-
// Linear's hosted MCP server (https://mcp.linear.app/mcp) speaks the official
|
|
1095
|
-
// Model Context Protocol over Streamable HTTP. This bridge:
|
|
1096
|
-
// - connects with the installed @modelcontextprotocol/sdk Client +
|
|
1097
|
-
// StreamableHTTPClientTransport (the same transport dsh-mcp-client uses),
|
|
1098
|
-
// - authenticates with `Authorization: Bearer <token>` — Linear's hosted MCP
|
|
1099
|
-
// endpoint expects the Bearer scheme, unlike the raw-value GraphQL header;
|
|
1100
|
-
// the token is only ever placed in request headers, never in a tool name,
|
|
1101
|
-
// description, schema, or log line,
|
|
1102
|
-
// - DISCOVERS the server's tools at connect time with `tools/list` (paginated)
|
|
1103
|
-
// and registers one harness tool per discovered MCP tool, so no Linear MCP
|
|
1104
|
-
// API is hand-invented here,
|
|
1105
|
-
// - forwards every call with `tools/call` using the RAW wire name (never the
|
|
1106
|
-
// public name), mirroring dsh-mcp-client's naming contract:
|
|
1107
|
-
// public name = `linear_mcp_<rawName>` when it fits the DeepSeek
|
|
1108
|
-
// function-name contract (≤64 chars, [A-Za-z0-9_-]), otherwise a lossy
|
|
1109
|
-
// normalization plus a 12-hex SHA-256 hash of the identity so distinct
|
|
1110
|
-
// MCP identities never collapse onto one public name. Names are a pure
|
|
1111
|
-
// function of the discovered raw name → stable across reconnects,
|
|
1112
|
-
// - on `notifications/tools/list_changed` re-discovers and atomically swaps
|
|
1113
|
-
// the registration generation (fetch fully first; only then dispose the old
|
|
1114
|
-
// generation and register the new one),
|
|
1115
|
-
// - on connection loss rebuilds the whole generation with bounded exponential
|
|
1116
|
-
// backoff, and unregisters when reconnect is disabled or exhausted.
|
|
1117
|
-
//
|
|
1118
|
-
// The bridge is fail-soft: without an MCP token (LINEAR_MCP_TOKEN / config),
|
|
1119
|
-
// with no loadable MCP SDK, or in a minimal ctx that lacks `ctx.effect`, the
|
|
1120
|
-
// GraphQL suite still registers and the bridge simply stays off.
|
|
1121
|
-
|
|
1122
|
-
const DEFAULT_MCP_URL = 'https://mcp.linear.app/mcp'
|
|
1123
|
-
const MCP_NAME_PREFIX = 'linear_mcp_'
|
|
1124
|
-
const MAX_PUBLIC_NAME_LENGTH = 64
|
|
1125
|
-
const INVALID_NAME_CHARS = /[^A-Za-z0-9_-]/g
|
|
1126
|
-
const HASH_LENGTH = 12
|
|
1127
|
-
/** Defaults shared by the Reconnect schema and resolveReconnectPolicy. */
|
|
1128
|
-
const RECONNECT_DEFAULTS = Object.freeze({
|
|
1129
|
-
enabled: true,
|
|
1130
|
-
initialDelayMs: 500,
|
|
1131
|
-
maxDelayMs: 30000,
|
|
1132
|
-
maxAttempts: 10,
|
|
1133
|
-
})
|
|
1134
|
-
/** Default timeout for individual MCP tool calls (ms). */
|
|
1135
|
-
const DEFAULT_MCP_TOOL_CALL_TIMEOUT_MS = 60000
|
|
1136
|
-
|
|
1137
|
-
// Resolve the MCP SDK lazily but once, so a missing/unreachable SDK never
|
|
1138
|
-
// breaks the GraphQL tools that share this module.
|
|
1139
|
-
// The SDK (1.30.0, same copy dsh-mcp-client uses) has no RawCallToolResultSchema
|
|
1140
|
-
// export; mirror dsh-mcp-client's raw record schema — `z.record(z.string(),
|
|
1141
|
-
// z.unknown())` from the same zod the SDK resolves to — so the bridge owns
|
|
1142
|
-
// result validation instead of the SDK pre-validating a different contract.
|
|
1143
|
-
// DSH_LINEAR_MCP_SDK_ZOD overrides the zod URL for offline tests.
|
|
1144
|
-
let mcpSdkPromise
|
|
1145
|
-
function loadMcpSdk() {
|
|
1146
|
-
mcpSdkPromise ??= (async () => {
|
|
1147
|
-
const sdk = await import(resolveMcpSdkClientEntry())
|
|
1148
|
-
const transport = await import(resolveMcpSdkTransportEntry())
|
|
1149
|
-
const types = await import(resolveMcpSdkTypesEntry())
|
|
1150
|
-
const zod = await import(resolveMcpSdkZodEntry())
|
|
1151
|
-
return {
|
|
1152
|
-
Client: sdk.Client,
|
|
1153
|
-
StreamableHTTPClientTransport: transport.StreamableHTTPClientTransport,
|
|
1154
|
-
ListToolsResultSchema: types.ListToolsResultSchema,
|
|
1155
|
-
ToolListChangedNotificationSchema: types.ToolListChangedNotificationSchema,
|
|
1156
|
-
// Same raw record schema dsh-mcp-client builds (zod v4 `z` namespace from 'zod/v4').
|
|
1157
|
-
RawCallToolResultSchema: zod.z.record(zod.z.string(), zod.z.unknown()),
|
|
1158
|
-
}
|
|
1159
|
-
})()
|
|
1160
|
-
return mcpSdkPromise
|
|
1161
|
-
}
|
|
1162
|
-
|
|
1163
|
-
// Deterministic public name for one discovered MCP tool (see naming contract above).
|
|
1164
|
-
// The 64-char function-name limit is measured against the PUBLIC name. Two defects
|
|
1165
|
-
// this bridge's own tests caught:
|
|
1166
|
-
// - the old `normalized === joined` guard threw away a perfectly valid readable
|
|
1167
|
-
// name whenever normalization changed anything ("get.issues/v2" → hashed), and
|
|
1168
|
-
// - the truncation was taken from the RAW name, silently dropping the
|
|
1169
|
-
// linear_mcp_ namespace prefix that keeps these tools distinct from every
|
|
1170
|
-
// other plugin's. Truncate the prefixed name instead.
|
|
1171
|
-
function publicToolName(rawName) {
|
|
1172
|
-
const joined = `${MCP_NAME_PREFIX}${rawName}`
|
|
1173
|
-
const normalized = joined.replace(INVALID_NAME_CHARS, '_')
|
|
1174
|
-
if (normalized.length <= MAX_PUBLIC_NAME_LENGTH) return normalized
|
|
1175
|
-
const hash = createHash('sha256').update(`linear\0${rawName}`).digest('hex').slice(0, HASH_LENGTH)
|
|
1176
|
-
return `${normalized.slice(0, MAX_PUBLIC_NAME_LENGTH - HASH_LENGTH - 1)}_${hash}`
|
|
1177
|
-
}
|
|
1178
|
-
|
|
1179
|
-
// Keep a supported advertised output schema; unsupported MCP vocabulary falls
|
|
1180
|
-
// back to unconstrained (annotation-only), exactly like dsh-mcp-client.
|
|
1181
|
-
function supportedOutputSchema(candidate) {
|
|
1182
|
-
if (candidate === undefined) return undefined
|
|
1183
|
-
try {
|
|
1184
|
-
assertSupportedJsonSchema(candidate)
|
|
1185
|
-
return candidate
|
|
1186
|
-
} catch {
|
|
1187
|
-
return undefined
|
|
1188
|
-
}
|
|
1189
|
-
}
|
|
1190
|
-
|
|
1191
|
-
// Canonical output contract for one proxied MCP tool.
|
|
1192
|
-
function createMcpOutput(rawName, structuredSchema) {
|
|
1193
|
-
return {
|
|
1194
|
-
schema: {
|
|
1195
|
-
type: 'object',
|
|
1196
|
-
properties: {
|
|
1197
|
-
content: { type: 'array', items: {} },
|
|
1198
|
-
structuredContent: structuredSchema ?? {},
|
|
1199
|
-
},
|
|
1200
|
-
required: structuredSchema === undefined ? ['content'] : ['content', 'structuredContent'],
|
|
1201
|
-
additionalProperties: false,
|
|
1202
|
-
},
|
|
1203
|
-
render(_args, value) {
|
|
1204
|
-
return [{ type: 'text', text: extractText(value.content, rawName) }]
|
|
1205
|
-
},
|
|
1206
|
-
}
|
|
1207
|
-
}
|
|
1208
|
-
|
|
1209
|
-
// ---------------------------------------------------------------------------
|
|
1210
|
-
// Image-result projection (parity with @deepseek-ai/dsh-mcp-client's
|
|
1211
|
-
// prepareImageProjection / finalizeContent pair)
|
|
1212
|
-
//
|
|
1213
|
-
// An MCP result may carry `image` blocks holding raw base64. The harness model
|
|
1214
|
-
// vocabulary carries images as DURABLE attachment references, not inline bytes,
|
|
1215
|
-
// so the bridge: (1) returns the canonical raw value unchanged — programmatic
|
|
1216
|
-
// callers keep every byte; (2) prepares a projection that swaps each image for
|
|
1217
|
-
// its stored reference through the host's own admission pipeline
|
|
1218
|
-
// (`ctx.attachments.saveImages`, which owns the count / aggregate-byte /
|
|
1219
|
-
// media-type limits); and (3) hands the harness that content at the last mile
|
|
1220
|
-
// through `finalizeContent`. Any refusal — an invalid block, no mounted store,
|
|
1221
|
-
// a model route without image input, or an admission rejection — keeps the
|
|
1222
|
-
// existing text projection and only replaces the image with a diagnostic.
|
|
1223
|
-
// Text and error behaviour are unchanged either way.
|
|
1224
|
-
// ---------------------------------------------------------------------------
|
|
1225
|
-
|
|
1226
|
-
/** Raster formats the durable attachment vocabulary supports. */
|
|
1227
|
-
const IMAGE_MEDIA_TYPES = ['image/png', 'image/jpeg', 'image/webp', 'image/gif']
|
|
1228
|
-
|
|
1229
|
-
/** Canonical RFC 4648 base64, excluding whitespace and URL-safe aliases. */
|
|
1230
|
-
const CANONICAL_BASE64 = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/
|
|
1231
|
-
|
|
1232
|
-
/** Narrow one JSON value to a string-keyed object (untrusted MCP content). */
|
|
1233
|
-
function isRecord(value) {
|
|
1234
|
-
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
1235
|
-
}
|
|
1236
|
-
|
|
1237
|
-
/** Narrow a declared MIME string to the durable image vocabulary. */
|
|
1238
|
-
function isImageMediaType(value) {
|
|
1239
|
-
return IMAGE_MEDIA_TYPES.includes(value)
|
|
1240
|
-
}
|
|
1241
|
-
|
|
1242
|
-
/** Whether an untrusted MCP content array contains a declared image block. */
|
|
1243
|
-
function containsImage(content) {
|
|
1244
|
-
return Array.isArray(content) && content.some((value) => isRecord(value) && value.type === 'image')
|
|
1245
|
-
}
|
|
1246
|
-
|
|
1247
|
-
/** Decode one untrusted MCP image block without accepting base64 aliases. */
|
|
1248
|
-
function decodeImage(block) {
|
|
1249
|
-
if (block.mimeType === undefined || !isImageMediaType(block.mimeType)) throw new Error('the declared media type is not PNG, JPEG, WebP, or GIF')
|
|
1250
|
-
// `typeof` first: an untrusted block may carry a number or object where the
|
|
1251
|
-
// spec declares a base64 string, and CANONICAL_BASE64.test() would coerce it.
|
|
1252
|
-
if (typeof block.data !== 'string' || !CANONICAL_BASE64.test(block.data)) throw new Error('the image data is not canonical base64')
|
|
1253
|
-
const data = Buffer.from(block.data, 'base64')
|
|
1254
|
-
if (data.toString('base64') !== block.data) throw new Error('the image data is not canonical base64')
|
|
1255
|
-
return { data, mediaType: block.mimeType }
|
|
1256
|
-
}
|
|
1257
|
-
|
|
1258
|
-
/** Stable diagnostic text for an image block that was not admitted. */
|
|
1259
|
-
function imageDiagnostic(block, reason) {
|
|
1260
|
-
return `[image unavailable: ${block?.mimeType ?? 'unknown media type'}; ${reason}; raw image data remains available to programmatic callers]`
|
|
1261
|
-
}
|
|
1262
|
-
|
|
1263
|
-
/**
|
|
1264
|
-
* Recognize a caller-correctable image admission refusal from the mounted
|
|
1265
|
-
* attachment store WITHOUT importing @deepseek-ai/dsh-attachment: the plugin
|
|
1266
|
-
* resolves its peers by absolute file:// URL, and that package is not one of
|
|
1267
|
-
* them. Its AttachmentError class deliberately re-implements the HarnessError
|
|
1268
|
-
* shape so consumers route on `code`, never on the prototype chain — the same
|
|
1269
|
-
* rule that makes it cross duplicate package installations works across a
|
|
1270
|
-
* module boundary we do not own. Codes are copied from that package's
|
|
1271
|
-
* IMAGE_ADMISSION_ERROR_CODES (see THIRD_PARTY_NOTICES.md).
|
|
1272
|
-
*/
|
|
1273
|
-
const IMAGE_ADMISSION_ERROR_CODES = new Set([
|
|
1274
|
-
'TOO_MANY_IMAGES',
|
|
1275
|
-
'IMAGES_TOO_LARGE',
|
|
1276
|
-
'UNSUPPORTED_IMAGE_TYPE',
|
|
1277
|
-
'INVALID_IMAGE_BASE64',
|
|
1278
|
-
'INVALID_IMAGE',
|
|
1279
|
-
'IMAGE_TYPE_MISMATCH',
|
|
1280
|
-
'IMAGE_TOO_LARGE',
|
|
1281
|
-
'IMAGE_TOO_MANY_PIXELS',
|
|
1282
|
-
'IMAGE_DIMENSION_TOO_LARGE',
|
|
1283
|
-
])
|
|
1284
|
-
|
|
1285
|
-
function isImageAdmissionError(error) {
|
|
1286
|
-
return error instanceof Error && typeof error.code === 'string' && IMAGE_ADMISSION_ERROR_CODES.has(error.code)
|
|
1287
|
-
}
|
|
1288
|
-
|
|
1289
|
-
/**
|
|
1290
|
-
* Prove that the route which will read this result accepts image input, then
|
|
1291
|
-
* hand back the durable store. Positive proof only: no mounted store, an
|
|
1292
|
-
* unresolvable route, or a model that does not declare image input all refuse.
|
|
1293
|
-
* @param ctx - plugin context carrying optional attachment and model services.
|
|
1294
|
-
* @param exec - exact tool execution whose agent supplies the latest route.
|
|
1295
|
-
* @returns the mounted attachment store.
|
|
1296
|
-
*/
|
|
1297
|
-
async function resolveImageAdmission(ctx, exec) {
|
|
1298
|
-
const attachments = typeof ctx?.get === 'function' ? ctx.get('attachments') : undefined
|
|
1299
|
-
if (attachments === undefined) throw new Error('no attachment store is mounted')
|
|
1300
|
-
const routed = exec.agent?.session?.requestHeader?.()?.config
|
|
1301
|
-
const provider = routed?.provider ?? exec.agent?.options?.provider
|
|
1302
|
-
const model = routed?.model ?? exec.agent?.options?.model
|
|
1303
|
-
const llm = typeof ctx.get === 'function' ? ctx.get('llm') : undefined
|
|
1304
|
-
if (provider === undefined || model === undefined || llm === undefined) throw new Error('the current model route could not be resolved')
|
|
1305
|
-
let info
|
|
1306
|
-
try {
|
|
1307
|
-
info = await llm.resolveModelInfo(provider, model, exec.signal)
|
|
1308
|
-
} catch {
|
|
1309
|
-
throw new Error('the current model route could not be verified')
|
|
1310
|
-
}
|
|
1311
|
-
if (info?.inputModalities === undefined || !info.inputModalities.includes('image')) throw new Error(`model "${model}" does not declare image input`)
|
|
1312
|
-
if (exec.signal?.aborted) throw new Error('the tool call was canceled before image storage')
|
|
1313
|
-
return attachments
|
|
1314
|
-
}
|
|
1315
|
-
|
|
1316
|
-
/**
|
|
1317
|
-
* Project ordered MCP blocks into the core content vocabulary. Text-like runs
|
|
1318
|
-
* are newline-coalesced; admitted images split those runs at their original
|
|
1319
|
-
* position. This is also what `extractText` runs through, so the text half of
|
|
1320
|
-
* the projection is exactly the pre-existing `extractText` string.
|
|
1321
|
-
*/
|
|
1322
|
-
function projectContent(mcpContent, toolName, image = (block) => ({ type: 'text', text: imageDiagnostic(block, 'this result was not admitted to durable model context') })) {
|
|
1323
|
-
const projected = []
|
|
1324
|
-
const text = []
|
|
1325
|
-
const flushText = () => {
|
|
1326
|
-
if (text.length === 0) return
|
|
1327
|
-
projected.push({ type: 'text', text: text.splice(0).join('\n') })
|
|
1328
|
-
}
|
|
1329
|
-
for (const [index, value] of (Array.isArray(mcpContent) ? mcpContent : []).entries()) {
|
|
1330
|
-
if (!isRecord(value)) {
|
|
1331
|
-
text.push('[unsupported MCP content block: expected an object]')
|
|
1332
|
-
continue
|
|
1333
|
-
}
|
|
1334
|
-
switch (value.type) {
|
|
1335
|
-
case 'text':
|
|
1336
|
-
if (value.text !== undefined) text.push(value.text)
|
|
1337
|
-
break
|
|
1338
|
-
case 'image':
|
|
1339
|
-
flushText()
|
|
1340
|
-
projected.push(image(value, index))
|
|
1341
|
-
break
|
|
1342
|
-
case 'resource_link':
|
|
1343
|
-
if (value.name === undefined || value.uri === undefined) text.push('[resource link unavailable: the MCP block is missing its name or URI]')
|
|
1344
|
-
else text.push(`Resource link: ${value.name} (${value.uri})`)
|
|
1345
|
-
break
|
|
1346
|
-
case 'audio':
|
|
1347
|
-
text.push(`[audio result unsupported: ${value.mimeType ?? 'unknown media type'}]`)
|
|
1348
|
-
break
|
|
1349
|
-
case 'resource':
|
|
1350
|
-
text.push('[embedded resource unsupported; raw resource data remains available to programmatic callers]')
|
|
1351
|
-
break
|
|
1352
|
-
default:
|
|
1353
|
-
text.push(`[unsupported MCP content type: ${value.type}]`)
|
|
1354
|
-
}
|
|
1355
|
-
}
|
|
1356
|
-
flushText()
|
|
1357
|
-
return projected.length > 0 ? projected : [{ type: 'text', text: `(${toolName} returned no model-visible content)` }]
|
|
1358
|
-
}
|
|
1359
|
-
|
|
1360
|
-
// Extract text from an MCP content array into one string (network trust
|
|
1361
|
-
// boundary: spec-required fields are guarded with fallbacks).
|
|
1362
|
-
function extractText(mcpContent, toolName) {
|
|
1363
|
-
return projectContent(mcpContent, toolName).map((block) => block.text).join('\n')
|
|
1364
|
-
}
|
|
1365
|
-
|
|
1366
|
-
/**
|
|
1367
|
-
* Decode, preflight, and durably save one MCP result's ordered image batch.
|
|
1368
|
-
* Any refusal projects every image as text while retaining the canonical raw
|
|
1369
|
-
* value for programmatic callers.
|
|
1370
|
-
*/
|
|
1371
|
-
async function prepareImageProjection(ctx, exec, content, toolName) {
|
|
1372
|
-
const decoded = []
|
|
1373
|
-
const validationErrors = new Map()
|
|
1374
|
-
const imageIndexes = []
|
|
1375
|
-
for (const [index, value] of content.entries()) {
|
|
1376
|
-
if (!isRecord(value) || value.type !== 'image') continue
|
|
1377
|
-
imageIndexes.push(index)
|
|
1378
|
-
try {
|
|
1379
|
-
decoded.push(decodeImage(value))
|
|
1380
|
-
} catch (error) {
|
|
1381
|
-
validationErrors.set(index, error.message)
|
|
1382
|
-
}
|
|
1383
|
-
}
|
|
1384
|
-
if (validationErrors.size > 0) {
|
|
1385
|
-
return projectContent(content, toolName, (block, index) => ({
|
|
1386
|
-
type: 'text',
|
|
1387
|
-
text: imageDiagnostic(block, validationErrors.get(index) ?? 'another image in the same result was invalid'),
|
|
1388
|
-
}))
|
|
1389
|
-
}
|
|
1390
|
-
let attachments
|
|
1391
|
-
try {
|
|
1392
|
-
attachments = await resolveImageAdmission(ctx, exec)
|
|
1393
|
-
} catch (error) {
|
|
1394
|
-
const reason = error?.message ?? String(error)
|
|
1395
|
-
return projectContent(content, toolName, (block) => ({ type: 'text', text: imageDiagnostic(block, reason) }))
|
|
1396
|
-
}
|
|
1397
|
-
try {
|
|
1398
|
-
const refs = await attachments.saveImages(decoded)
|
|
1399
|
-
const byIndex = new Map(imageIndexes.map((index, offset) => [index, refs[offset]]))
|
|
1400
|
-
return projectContent(content, toolName, (_block, index) => ({ type: 'image', attachment: byIndex.get(index) }))
|
|
1401
|
-
} catch (error) {
|
|
1402
|
-
const reason = isImageAdmissionError(error) ? `image admission rejected the result: ${error.message}` : 'durable image storage rejected the result'
|
|
1403
|
-
return projectContent(content, toolName, (block) => ({ type: 'text', text: imageDiagnostic(block, reason) }))
|
|
1404
|
-
}
|
|
1405
|
-
}
|
|
1406
|
-
|
|
1407
|
-
/**
|
|
1408
|
-
* The model-facing half of one MCP result — this bridge's `finalizeContent`.
|
|
1409
|
-
* It follows the upstream dsh-mcp-client shape exactly: the projection is
|
|
1410
|
-
* prepared in `execute` (the only async stage of the pipeline), keyed in a
|
|
1411
|
-
* module-local WeakMap on the exact `exec` object, and read back by the
|
|
1412
|
-
* synchronous finalizer the registry snapshots from the REGISTERED definition
|
|
1413
|
-
* at call start. Upstream's per-execution guards are preserved verbatim: an
|
|
1414
|
-
* error outcome is never rewritten, and a projection is applied only when the
|
|
1415
|
-
* outcome still carries what execute returned — `result.value` deep-equals the
|
|
1416
|
-
* recorded canonical value AND `result.content` deep-equals the recorded
|
|
1417
|
-
* rendered fallback. A result another wrapper rewrote or replaced keeps its own
|
|
1418
|
-
* content (returning undefined preserves it). Each projection is consumed on the
|
|
1419
|
-
* first read, exactly like upstream's `projections.delete(exec)`.
|
|
1420
|
-
* - image bytes always stay in the canonical `value` for programmatic
|
|
1421
|
-
* callers; only the model-visible content carries durable references;
|
|
1422
|
-
* - every refusal leaves the pre-existing text projection in place.
|
|
1423
|
-
* The callback is total: it never throws, and returns undefined to preserve.
|
|
1424
|
-
*/
|
|
1425
|
-
|
|
1426
|
-
/** Prepared projection plus the two recorded values upstream compares against. */
|
|
1427
|
-
const imageProjections = new WeakMap()
|
|
1428
|
-
|
|
1429
|
-
function projectMcpResultContent(exec, result) {
|
|
1430
|
-
try {
|
|
1431
|
-
const prepared = imageProjections.get(exec)
|
|
1432
|
-
if (prepared === undefined) return undefined
|
|
1433
|
-
// One projection per execution: read it once and release the key, so a
|
|
1434
|
-
// replayed finalizer for an already-settled call can never re-apply it. The
|
|
1435
|
-
// WeakMap entry is inert after that anyway — its identity is the exec object
|
|
1436
|
-
// the registry hands this callback, so nothing else can look it up.
|
|
1437
|
-
imageProjections.delete(exec)
|
|
1438
|
-
// Upstream's error guard: an outcome the registry turned into an error — a
|
|
1439
|
-
// throw, or a cancellation it replaced this value with — is left alone.
|
|
1440
|
-
if (result?.isError === true) return undefined
|
|
1441
|
-
// Upstream's per-exec guard: the outcome still carries what execute returned.
|
|
1442
|
-
if (!isDeepStrictEqual(result.value, prepared.value)) return undefined
|
|
1443
|
-
if (!isDeepStrictEqual(result.content, prepared.fallback)) return undefined
|
|
1444
|
-
return prepared.projected
|
|
1445
|
-
} catch {
|
|
1446
|
-
return undefined
|
|
1447
|
-
}
|
|
1448
|
-
}
|
|
1449
|
-
|
|
1450
|
-
// Credential-leak guard for diagnostics: strip anything that looks like a
|
|
1451
|
-
// bearer/API token before an error message reaches a log line or the model.
|
|
1452
|
-
// The li_ shape must match the WHOLE token run: Linear keys are
|
|
1453
|
-
// "li_api_<base64-ish body>" with '_' inside the body, so the old \b-anchored
|
|
1454
|
-
// /\bli_[A-Za-z0-9]+\b/ stopped at the first inner underscore and leaked the
|
|
1455
|
-
// entire secret body past the guard.
|
|
1456
|
-
const TOKEN_SHAPES = [/Bearer\s+[A-Za-z0-9._~+/-]+=*/gi, /\bli_[A-Za-z0-9_]+/g, /\bsk-[A-Za-z0-9]+/g]
|
|
1457
|
-
function redactSecrets(text) {
|
|
1458
|
-
let out = String(text)
|
|
1459
|
-
for (const re of TOKEN_SHAPES) out = out.replace(re, '[redacted]')
|
|
1460
|
-
return out
|
|
1461
|
-
}
|
|
1462
1380
|
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
const toolCallTimeoutMs = config.toolCallTimeoutMs ?? DEFAULT_MCP_TOOL_CALL_TIMEOUT_MS
|
|
1499
|
-
|
|
1500
|
-
let disposed = false
|
|
1501
|
-
/** The live generation's Client (undefined while down/backing off). */
|
|
1502
|
-
let client
|
|
1503
|
-
/** Live registrations owned by the current generation. */
|
|
1504
|
-
let disposers = new Map()
|
|
1505
|
-
let reconnectTimer
|
|
1506
|
-
/** Consecutive failed connection attempts within the current outage. */
|
|
1507
|
-
let failedAttempts = 0
|
|
1508
|
-
/** When the current generation finished connect + initial discovery. */
|
|
1509
|
-
let connectedAt
|
|
1510
|
-
/** The real error from the first attempt, for apply-time diagnostics. */
|
|
1511
|
-
let firstAttemptError
|
|
1512
|
-
/** Serializes every discovery swap so two can never interleave. */
|
|
1513
|
-
let syncChain = Promise.resolve()
|
|
1514
|
-
|
|
1515
|
-
const isCurrent = (generation) => !disposed && client === generation
|
|
1516
|
-
|
|
1517
|
-
// One effect slot owns ALL registrations of the current generation. It is
|
|
1518
|
-
// armed exactly once, during apply (so it belongs to the plugin fiber), and
|
|
1519
|
-
// always unregisters whatever is live at teardown — never a stale set.
|
|
1520
|
-
// (Cordis `_execute` rejects a *returned* function as "Invalid effect", so
|
|
1521
|
-
// the disposer is collected by returning an iterable of disposers.)
|
|
1522
|
-
ctx.effect(() => [
|
|
1523
|
-
() => {
|
|
1524
|
-
for (const dispose of disposers.values()) dispose()
|
|
1525
|
-
disposers = new Map()
|
|
1526
|
-
},
|
|
1527
|
-
], 'linear.mcp.tools')
|
|
1528
|
-
|
|
1529
|
-
/** Fetch the complete next generation, then atomically swap registrations. */
|
|
1530
|
-
async function syncTools(generation) {
|
|
1531
|
-
const sdk = await loadMcpSdk()
|
|
1532
|
-
const definitions = new Map()
|
|
1533
|
-
const seenCursors = new Set()
|
|
1534
|
-
let cursor
|
|
1535
|
-
do {
|
|
1536
|
-
// Uncached paginated tools/list (same idiom as dsh-mcp-client): a raw
|
|
1537
|
-
// request keeps the SDK's per-page schema cache from merging pages.
|
|
1538
|
-
const response = await generation.request(
|
|
1539
|
-
{ method: 'tools/list', ...(cursor === undefined ? {} : { params: { cursor } }) },
|
|
1540
|
-
sdk.ListToolsResultSchema,
|
|
1541
|
-
)
|
|
1542
|
-
for (const tool of response.tools) {
|
|
1543
|
-
const publicName = publicToolName(tool.name)
|
|
1544
|
-
if (definitions.has(publicName)) {
|
|
1545
|
-
throw new Error(`${label}: server listed tool "${tool.name}" more than once — invalid tool list`)
|
|
1546
|
-
}
|
|
1547
|
-
definitions.set(publicName, createMcpDefinition(ctx, generation, sdk, publicName, tool, config))
|
|
1548
|
-
}
|
|
1549
|
-
cursor = response.nextCursor
|
|
1550
|
-
if (cursor) {
|
|
1551
|
-
if (seenCursors.has(cursor)) throw new Error(`${label}: server repeated a tools/list continuation cursor — invalid tool list`)
|
|
1552
|
-
seenCursors.add(cursor)
|
|
1553
|
-
}
|
|
1554
|
-
} while (cursor)
|
|
1555
|
-
// Fetch phase succeeded — only now swap: dispose the old generation, then
|
|
1556
|
-
// register the new one. A registration conflict rolls the partial back.
|
|
1557
|
-
for (const dispose of disposers.values()) dispose()
|
|
1558
|
-
const next = new Map()
|
|
1559
|
-
try {
|
|
1560
|
-
for (const [publicName, definition] of definitions) next.set(publicName, ctx.tools.register(definition))
|
|
1561
|
-
} catch (error) {
|
|
1562
|
-
for (const dispose of next.values()) dispose()
|
|
1563
|
-
disposers = new Map()
|
|
1564
|
-
ctx.logger.error(`${label}: tool registration failed, no tools registered: ${String(error)}`)
|
|
1565
|
-
throw error
|
|
1566
|
-
}
|
|
1567
|
-
disposers = next
|
|
1568
|
-
return next.size
|
|
1569
|
-
}
|
|
1570
|
-
|
|
1571
|
-
function enqueueSync(generation) {
|
|
1572
|
-
const run = syncChain.then(async () => {
|
|
1573
|
-
if (!isCurrent(generation)) return
|
|
1574
|
-
await syncTools(generation)
|
|
1575
|
-
})
|
|
1576
|
-
syncChain = run.catch(() => {})
|
|
1577
|
-
return run
|
|
1578
|
-
}
|
|
1579
|
-
|
|
1580
|
-
/** One disconnect decision per generation (idempotent under racing close/error). */
|
|
1581
|
-
function generationDown(generation) {
|
|
1582
|
-
if (!isCurrent(generation)) return
|
|
1583
|
-
client = undefined
|
|
1584
|
-
scheduleReconnect()
|
|
1585
|
-
}
|
|
1586
|
-
|
|
1587
|
-
function scheduleReconnect() {
|
|
1588
|
-
const lostEstablishedConnection = connectedAt !== undefined
|
|
1589
|
-
if (!policy.enabled) {
|
|
1590
|
-
ctx.logger.error(`${label}: ${lostEstablishedConnection ? 'connection lost and reconnect is disabled' : 'connection failed and reconnect is disabled'} — Linear MCP tools are unavailable until the plugin reloads or the Host restarts`)
|
|
1591
|
-
return
|
|
1592
|
-
}
|
|
1593
|
-
// A connection that stayed up past maxDelayMs closes the outage, so a
|
|
1594
|
-
// crash-loop still exhausts the cap instead of restarting forever.
|
|
1595
|
-
if (connectedAt !== undefined && Date.now() - connectedAt >= policy.maxDelayMs) failedAttempts = 0
|
|
1596
|
-
connectedAt = undefined
|
|
1597
|
-
failedAttempts += 1
|
|
1598
|
-
if (failedAttempts > policy.maxAttempts) {
|
|
1599
|
-
syncChain = syncChain.then(() => {
|
|
1600
|
-
for (const dispose of disposers.values()) dispose()
|
|
1601
|
-
disposers = new Map()
|
|
1602
|
-
})
|
|
1603
|
-
ctx.logger.error(`${label}: giving up after ${policy.maxAttempts} consecutive failed reconnect attempts — Linear MCP tools unregistered; reload the plugin or restart the Host to reconnect`)
|
|
1604
|
-
return
|
|
1605
|
-
}
|
|
1606
|
-
const delayMs = Math.min(policy.maxDelayMs, policy.initialDelayMs * 2 ** (failedAttempts - 1))
|
|
1607
|
-
ctx.logger.warn(`${label}: ${lostEstablishedConnection ? 'connection lost; reconnecting' : 'connection failed; retrying'} in ${delayMs}ms (attempt ${failedAttempts}/${policy.maxAttempts})`)
|
|
1608
|
-
reconnectTimer = setTimeout(() => {
|
|
1609
|
-
reconnectTimer = undefined
|
|
1610
|
-
connectGeneration(false)
|
|
1611
|
-
}, delayMs)
|
|
1612
|
-
reconnectTimer.unref()
|
|
1613
|
-
}
|
|
1614
|
-
|
|
1615
|
-
/** One connection attempt. Never rejects; every failure funnels through generationDown. */
|
|
1616
|
-
async function connectGeneration(startup) {
|
|
1617
|
-
let sdk
|
|
1618
|
-
try {
|
|
1619
|
-
sdk = await loadMcpSdk()
|
|
1620
|
-
} catch (error) {
|
|
1621
|
-
if (firstAttemptError === undefined) firstAttemptError = error
|
|
1622
|
-
ctx.logger.error(`${label}: the MCP SDK could not be loaded — Linear MCP tools are unavailable: ${String(error)}`)
|
|
1623
|
-
return
|
|
1624
|
-
}
|
|
1625
|
-
const generation = new sdk.Client({ name: 'dsh-linear-mcp-bridge', version: '0.1.0' }, { capabilities: {} })
|
|
1626
|
-
client = generation
|
|
1627
|
-
// A close observed DURING the attempt is handled by the attempt's own
|
|
1628
|
-
// settle path; only a post-settle close triggers the reconnect decision.
|
|
1629
|
-
let attemptSettled = false
|
|
1630
|
-
generation.onclose = () => {
|
|
1631
|
-
if (attemptSettled && isCurrent(generation)) generationDown(generation)
|
|
1632
|
-
}
|
|
1633
|
-
generation.onerror = (error) => {
|
|
1634
|
-
// Transport-level errors are logged; a dead transport also fires onclose.
|
|
1635
|
-
ctx.logger.warn(`${label}: transport error: ${redactSecrets(error?.message ?? error)}`)
|
|
1636
|
-
}
|
|
1637
|
-
generation.setNotificationHandler(sdk.ToolListChangedNotificationSchema, async () => {
|
|
1638
|
-
if (!isCurrent(generation)) return
|
|
1639
|
-
ctx.logger.info(`${label}: tool list changed, re-syncing`)
|
|
1640
|
-
try {
|
|
1641
|
-
await enqueueSync(generation)
|
|
1642
|
-
} catch (error) {
|
|
1643
|
-
if (!disposed) ctx.logger.error(`${label}: tool re-sync failed: ${String(error)}`)
|
|
1644
|
-
}
|
|
1645
|
-
})
|
|
1646
|
-
// Credentials live ONLY in this header map, rebuilt per generation.
|
|
1647
|
-
const headers = { Authorization: `Bearer ${config.token}` }
|
|
1648
|
-
try {
|
|
1649
|
-
await generation.connect(new sdk.StreamableHTTPClientTransport(new URL(config.url), { requestInit: { headers } }))
|
|
1650
|
-
if (generation.transport === undefined) {
|
|
1651
|
-
// The transport closed itself during connect(): settle the attempt and
|
|
1652
|
-
// let the reconnect scheduler take over.
|
|
1653
|
-
attemptSettled = true
|
|
1654
|
-
if (isCurrent(generation)) generationDown(generation)
|
|
1655
|
-
return
|
|
1656
|
-
}
|
|
1657
|
-
if (startup) {
|
|
1658
|
-
// Startup registration failure is fatal to apply so the operator sees it.
|
|
1659
|
-
const count = await syncTools(generation)
|
|
1660
|
-
ctx.logger.info(`${label}: connected to ${config.url}, registered ${count} Linear MCP tools`)
|
|
1661
|
-
} else {
|
|
1662
|
-
await enqueueSync(generation)
|
|
1663
|
-
if (failedAttempts > 0) ctx.logger.info(`${label}: reconnected and re-synced tools (attempt ${failedAttempts}/${policy.maxAttempts})`)
|
|
1664
|
-
}
|
|
1665
|
-
} catch (error) {
|
|
1666
|
-
if (firstAttemptError === undefined) firstAttemptError = error
|
|
1667
|
-
if (isCurrent(generation)) ctx.logger.warn(`${label}: connection attempt failed: ${authHint(error)}`)
|
|
1668
|
-
try {
|
|
1669
|
-
await generation.close()
|
|
1670
|
-
} catch {}
|
|
1671
|
-
attemptSettled = true
|
|
1672
|
-
if (!isCurrent(generation)) return
|
|
1673
|
-
generationDown(generation)
|
|
1674
|
-
return
|
|
1675
|
-
}
|
|
1676
|
-
attemptSettled = true
|
|
1677
|
-
if (!isCurrent(generation)) return
|
|
1678
|
-
connectedAt = Date.now()
|
|
1679
|
-
}
|
|
1680
|
-
|
|
1681
|
-
// Surface an actionable hint for auth failures without echoing any secret.
|
|
1682
|
-
function authHint(error) {
|
|
1683
|
-
const text = String(error?.message ?? error)
|
|
1684
|
-
if (/401|Unauthorized/i.test(text)) return `${text} — check that the Linear MCP token (LINEAR_MCP_TOKEN / config mcp.token) is valid for ${config.url}`
|
|
1685
|
-
return text
|
|
1686
|
-
}
|
|
1687
|
-
|
|
1688
|
-
const settling = connectGeneration(true)
|
|
1689
|
-
ctx.effect(() => [
|
|
1690
|
-
() => {
|
|
1691
|
-
disposed = true
|
|
1692
|
-
if (reconnectTimer !== undefined) {
|
|
1693
|
-
clearTimeout(reconnectTimer)
|
|
1694
|
-
reconnectTimer = undefined
|
|
1695
|
-
}
|
|
1696
|
-
const current = client
|
|
1697
|
-
client = undefined
|
|
1698
|
-
return async () => {
|
|
1699
|
-
if (current !== undefined) {
|
|
1700
|
-
try {
|
|
1701
|
-
await current.close()
|
|
1702
|
-
} catch {}
|
|
1703
|
-
}
|
|
1704
|
-
await settling
|
|
1705
|
-
await syncChain
|
|
1706
|
-
for (const dispose of disposers.values()) dispose()
|
|
1707
|
-
disposers = new Map()
|
|
1708
|
-
}
|
|
1709
|
-
},
|
|
1710
|
-
], 'linear.mcp.connection')
|
|
1711
|
-
|
|
1712
|
-
return {
|
|
1713
|
-
/** Settles after the startup attempt; carries its error without throwing. */
|
|
1714
|
-
ready: settling.then(() => firstAttemptError),
|
|
1715
|
-
}
|
|
1716
|
-
}
|
|
1717
|
-
|
|
1718
|
-
/** Build one harness tool definition for a discovered MCP tool. */
|
|
1719
|
-
function createMcpDefinition(ctx, client, sdk, publicName, tool, config) {
|
|
1720
|
-
const rawName = tool.name
|
|
1721
|
-
return {
|
|
1722
|
-
name: publicName,
|
|
1723
|
-
description: tool.description ?? '',
|
|
1724
|
-
// Pass the DISCOVERED input schema straight through; this plugin never
|
|
1725
|
-
// hand-models Linear's MCP API surface.
|
|
1726
|
-
parameters: tool.inputSchema ?? {},
|
|
1727
|
-
output: createMcpOutput(rawName, supportedOutputSchema(tool.outputSchema)),
|
|
1728
|
-
timeoutMs: config.toolCallTimeoutMs ?? DEFAULT_MCP_TOOL_CALL_TIMEOUT_MS,
|
|
1729
|
-
// Last-mile model-facing projection for image-bearing results (see
|
|
1730
|
-
// projectMcpResultContent). Declared on the definition so the registry
|
|
1731
|
-
// snapshots it per execution; the value it never touches stays canonical.
|
|
1732
|
-
finalizeContent(exec, result) {
|
|
1733
|
-
return projectMcpResultContent(exec, result)
|
|
1734
|
-
},
|
|
1735
|
-
async execute(args, exec) {
|
|
1736
|
-
const result = await client.request(
|
|
1737
|
-
{ method: 'tools/call', params: { name: rawName, arguments: typeof args === 'object' && args !== null ? args : {} } },
|
|
1738
|
-
// Raw record schema (same as dsh-mcp-client): the bridge owns result
|
|
1739
|
-
// validation instead of the SDK pre-validating a different contract.
|
|
1740
|
-
sdk.RawCallToolResultSchema ?? { parse: (v) => v },
|
|
1741
|
-
{ signal: exec.signal, timeout: config.toolCallTimeoutMs ?? DEFAULT_MCP_TOOL_CALL_TIMEOUT_MS },
|
|
1742
|
-
)
|
|
1743
|
-
if (result.isError === true) throw new Error(redactSecrets(extractText(result.content, rawName)))
|
|
1744
|
-
const content = Array.isArray(result.content) ? result.content : []
|
|
1745
|
-
// Awaited HERE — the only async stage of the pipeline. finalizeContent is
|
|
1746
|
-
// synchronous, so a projection started there could never land: an
|
|
1747
|
-
// image-bearing result must be projected before this body returns.
|
|
1748
|
-
if (containsImage(content)) {
|
|
1749
|
-
const projected = await prepareImageProjection(ctx, exec, content, rawName)
|
|
1750
|
-
// Keyed on the exact execution the registry snapshots its finalizer for.
|
|
1751
|
-
// The two recorded values are what the finalizer compares the outcome
|
|
1752
|
-
// against: this body's canonical value, and the model-visible fallback —
|
|
1753
|
-
// `render` is createMcpOutput's text-only projection of that same value,
|
|
1754
|
-
// so its snapshot equals this one block array. A structuredContent field
|
|
1755
|
-
// rides alongside `content` in the value but never in the rendered
|
|
1756
|
-
// content, so it is recorded on the value half only.
|
|
1757
|
-
// The recorded value must be EXACTLY what this body returns below —
|
|
1758
|
-
// the finalizer deep-equals `result.value` against it, so a recorded
|
|
1759
|
-
// `{ content }` never matches an outcome that carries structuredContent
|
|
1760
|
-
// alongside it, and the projection would be silently dropped (the model
|
|
1761
|
-
// would see the text diagnostic while the raw bytes stayed canonical).
|
|
1762
|
-
const value = {
|
|
1763
|
-
content,
|
|
1764
|
-
...(result.structuredContent !== undefined ? { structuredContent: result.structuredContent } : {}),
|
|
1381
|
+
// -- Direct GraphQL escape hatch -------------------------------------------
|
|
1382
|
+
//
|
|
1383
|
+
// The one tool that is not a hand-modelled endpoint: it forwards the caller's
|
|
1384
|
+
// own GraphQL document to the SAME fixed endpoint (API_URL) with the SAME
|
|
1385
|
+
// configured key, through the same gql() client and the same { error } guard
|
|
1386
|
+
// every other tool runs under. There is no second endpoint, no extra config,
|
|
1387
|
+
// and no credential of its own.
|
|
1388
|
+
//
|
|
1389
|
+
// Queries AND mutations are allowed — that is the point of the escape hatch —
|
|
1390
|
+
// so what a call may actually do is bounded only by the permissions attached to
|
|
1391
|
+
// the configured Linear API key, exactly as they are for every other tool here.
|
|
1392
|
+
register(
|
|
1393
|
+
defineTool({
|
|
1394
|
+
name: 'linear_graphql',
|
|
1395
|
+
description:
|
|
1396
|
+
'Direct GraphQL escape hatch: send an arbitrary GraphQL document (query OR mutation) straight to the Linear API and get the raw `data` back as JSON. Use it for anything the named linear_* tools do not cover — coverage is whatever the Linear GraphQL API exposes to your key, so this is NOT a substitute for named-tool parity and nothing is projected or normalised. `query` is required and must be non-blank; `variables` is an optional object of GraphQL variable values (prefer variables over inlining values into the document). Both queries and mutations are sent as-is: what they may read or change is governed by your Linear API key permissions, so a mutation really writes.',
|
|
1397
|
+
parameters: {
|
|
1398
|
+
query: { type: 'string', required: true, description: 'The GraphQL document to execute (a query or a mutation)' },
|
|
1399
|
+
// Explicit properties keep common variable shapes documented; openness
|
|
1400
|
+
// stays true because the document decides which variables exist.
|
|
1401
|
+
variables: {
|
|
1402
|
+
type: 'object',
|
|
1403
|
+
additionalProperties: true,
|
|
1404
|
+
description: 'Optional GraphQL variables object, keyed by variable name (e.g. { "id": "<uuid>" })',
|
|
1405
|
+
},
|
|
1406
|
+
},
|
|
1407
|
+
output: {
|
|
1408
|
+
schema: { type: 'string' },
|
|
1409
|
+
render: (_a, v) => [{ type: 'text', text: v }],
|
|
1410
|
+
},
|
|
1411
|
+
async execute(args) {
|
|
1412
|
+
// The parameter schema enforces the string; this is the non-blank half of
|
|
1413
|
+
// "required nonblank", checked before anything reaches the network.
|
|
1414
|
+
if (typeof args.query !== 'string' || !args.query.trim()) {
|
|
1415
|
+
return JSON.stringify({ error: '"query" must be a non-empty GraphQL document' })
|
|
1765
1416
|
}
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
},
|
|
1774
|
-
}
|
|
1417
|
+
const data = await gql(key(), args.query, args.variables)
|
|
1418
|
+
// Raw pass-through: the document chooses the shape, so the result is
|
|
1419
|
+
// returned as-is rather than projected through a fixed field set.
|
|
1420
|
+
return JSON.stringify({ data }, null, 2)
|
|
1421
|
+
},
|
|
1422
|
+
}),
|
|
1423
|
+
)
|
|
1775
1424
|
}
|
|
1776
1425
|
|
|
1777
1426
|
// ---------------------------------------------------------------------------
|
|
@@ -1782,108 +1431,30 @@ function createMcpDefinition(ctx, client, sdk, publicName, tool, config) {
|
|
|
1782
1431
|
* Plugin config:
|
|
1783
1432
|
* apiKey: Linear personal API key (li_api_...) for the GraphQL tools.
|
|
1784
1433
|
* Falls back to the LINEAR_API_KEY environment variable when unset.
|
|
1785
|
-
* mcp: optional MCP-bridge config (Linear hosted MCP server parity):
|
|
1786
|
-
* token — bearer token; falls back to LINEAR_MCP_TOKEN, then to
|
|
1787
|
-
* the same key resolution as `apiKey` (config apiKey or
|
|
1788
|
-
* LINEAR_API_KEY). The value is used ONLY in the
|
|
1789
|
-
* Authorization header of requests to `url`.
|
|
1790
|
-
* url — Streamable HTTP endpoint (default https://mcp.linear.app/mcp).
|
|
1791
|
-
* toolCallTimeoutMs / reconnect — call timeout and bounded backoff.
|
|
1792
1434
|
*
|
|
1793
|
-
* The
|
|
1794
|
-
*
|
|
1795
|
-
*
|
|
1796
|
-
* var; the authoritative resolution happens in resolveKey which checks
|
|
1797
|
-
* `config?.apiKey` first.
|
|
1435
|
+
* There is no other config. The plugin has no MCP side: it neither hosts an
|
|
1436
|
+
* MCP server nor connects to one, and it reads no MCP-related setting or
|
|
1437
|
+
* environment variable.
|
|
1798
1438
|
*/
|
|
1799
|
-
// Cordis
|
|
1439
|
+
// Cordis validates a plugin's `Config` as a Standard Schema (it calls
|
|
1800
1440
|
// Config["~standard"].validate(...)). The real API key still comes from the
|
|
1801
1441
|
// LINEAR_API_KEY env var (see resolveKey); this schema only needs to accept an
|
|
1802
1442
|
// optional `apiKey` string so a patch row may also supply one. `.default('')`
|
|
1803
1443
|
// makes a missing field valid; the empty value is ignored by resolveKey, which
|
|
1804
1444
|
// prefers config.apiKey only when it is non-empty and otherwise falls back to env.
|
|
1805
|
-
const Reconnect = z.object({
|
|
1806
|
-
enabled: z.boolean().default(RECONNECT_DEFAULTS.enabled),
|
|
1807
|
-
initialDelayMs: z.number().min(1).max(2147483647).default(RECONNECT_DEFAULTS.initialDelayMs),
|
|
1808
|
-
maxDelayMs: z.number().min(1).max(2147483647).default(RECONNECT_DEFAULTS.maxDelayMs),
|
|
1809
|
-
maxAttempts: z.number().step(1).min(1).max(Number.MAX_SAFE_INTEGER).default(RECONNECT_DEFAULTS.maxAttempts),
|
|
1810
|
-
})
|
|
1811
|
-
|
|
1812
|
-
const McpConfig = z.object({
|
|
1813
|
-
token: z.string().default(''),
|
|
1814
|
-
url: z.string().default(DEFAULT_MCP_URL),
|
|
1815
|
-
toolCallTimeoutMs: z.number().min(1).default(DEFAULT_MCP_TOOL_CALL_TIMEOUT_MS),
|
|
1816
|
-
reconnect: Reconnect,
|
|
1817
|
-
})
|
|
1818
|
-
|
|
1819
1445
|
const Config = z.object({
|
|
1820
1446
|
apiKey: z.string().default(''),
|
|
1821
|
-
mcp: McpConfig,
|
|
1822
1447
|
})
|
|
1823
1448
|
|
|
1824
|
-
|
|
1825
|
-
* Resolve the MCP bearer token without ever printing it. Precedence:
|
|
1826
|
-
* config.mcp.token → LINEAR_MCP_TOKEN → the GraphQL key resolution chain.
|
|
1827
|
-
*/
|
|
1828
|
-
function resolveMcpToken(config) {
|
|
1829
|
-
const token = config?.mcp?.token || process.env.LINEAR_MCP_TOKEN || config?.apiKey || process.env.LINEAR_API_KEY
|
|
1830
|
-
if (!token) throw new Error('Linear MCP token not configured: set the LINEAR_MCP_TOKEN environment variable (or pass `mcp.token` / `apiKey` in plugin config).')
|
|
1831
|
-
return token
|
|
1832
|
-
}
|
|
1833
|
-
|
|
1834
|
-
// The bridge is opt-in and fail-soft: a missing token or an unreachable MCP
|
|
1835
|
-
// server must never take down the GraphQL tool suite that shares this plugin.
|
|
1836
|
-
// LINEAR_MCP_DISABLED=1 turns it off; otherwise it starts only when an MCP
|
|
1837
|
-
// token is configured AND the host ctx supports effect-scoped teardown
|
|
1838
|
-
// (ctx.effect) — a bare test ctx without lifecycle support keeps the bridge off.
|
|
1839
|
-
const MCP_BRIDGE_ENABLED = process.env.LINEAR_MCP_DISABLED !== '1'
|
|
1840
|
-
|
|
1841
|
-
async function apply(ctx, config) {
|
|
1449
|
+
function apply(ctx, config) {
|
|
1842
1450
|
registerLinearTools(ctx, config)
|
|
1843
|
-
if (!MCP_BRIDGE_ENABLED || typeof ctx.effect !== 'function') return
|
|
1844
|
-
let mcpConfig
|
|
1845
|
-
try {
|
|
1846
|
-
mcpConfig = {
|
|
1847
|
-
token: resolveMcpToken(config),
|
|
1848
|
-
url: config?.mcp?.url || DEFAULT_MCP_URL,
|
|
1849
|
-
toolCallTimeoutMs: config?.mcp?.toolCallTimeoutMs,
|
|
1850
|
-
reconnect: config?.mcp?.reconnect,
|
|
1851
|
-
}
|
|
1852
|
-
} catch (err) {
|
|
1853
|
-
ctx.logger.warn(`linear-mcp: bridge disabled — ${err?.message ?? err}`)
|
|
1854
|
-
return
|
|
1855
|
-
}
|
|
1856
|
-
const connection = startMcpConnection(ctx, mcpConfig)
|
|
1857
|
-
// Startup attempt settles inside apply (cordis awaits an async apply), so a
|
|
1858
|
-
// failure here is reported once, clearly, and does not throw the fiber down.
|
|
1859
|
-
await connection.ready
|
|
1860
1451
|
}
|
|
1861
1452
|
|
|
1862
|
-
//
|
|
1863
|
-
//
|
|
1864
|
-
// auth, forwarding and disposal in isolation. Cordis's namespace unwrap keys off
|
|
1865
|
-
// { name, inject, apply }, so extra named exports are inert for the loader.
|
|
1453
|
+
// Cordis's namespace unwrap keys off { name, inject, apply }; this module
|
|
1454
|
+
// exports exactly that plugin surface and nothing else.
|
|
1866
1455
|
export {
|
|
1867
1456
|
Config,
|
|
1868
1457
|
apply,
|
|
1869
1458
|
inject,
|
|
1870
1459
|
name,
|
|
1871
|
-
// MCP bridge internals (test-visible; no behaviour change)
|
|
1872
|
-
createMcpDefinition,
|
|
1873
|
-
createMcpOutput,
|
|
1874
|
-
extractText,
|
|
1875
|
-
publicToolName,
|
|
1876
|
-
redactSecrets,
|
|
1877
|
-
resolveMcpToken,
|
|
1878
|
-
resolveReconnectPolicy,
|
|
1879
|
-
startMcpConnection,
|
|
1880
|
-
supportedOutputSchema,
|
|
1881
|
-
// Image-result projection internals (test-visible)
|
|
1882
|
-
decodeImage,
|
|
1883
|
-
containsImage,
|
|
1884
|
-
imageDiagnostic,
|
|
1885
|
-
isImageAdmissionError,
|
|
1886
|
-
prepareImageProjection,
|
|
1887
|
-
projectContent,
|
|
1888
|
-
projectMcpResultContent,
|
|
1889
1460
|
}
|