@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 CHANGED
@@ -4,157 +4,164 @@ Linear issue-tracking tools for the DeepSeek Harness. A Cordis function/namespac
4
4
  plugin (`export { name, inject, apply }`, no default export) that registers
5
5
  model-facing tools through `ctx.tools.register`.
6
6
 
7
- It exposes **two tool surfaces**:
8
-
9
- 1. **GraphQL tools (`linear_*`)** — 20 hand-written tools over the Linear GraphQL
10
- API (`https://api.linear.app/graphql`), authenticated with a personal API token.
11
- 2. **MCP parity tools (`linear_mcp_*`)** — a dynamic bridge to Linear's **hosted
12
- MCP server** (`https://mcp.linear.app/mcp`) over the official Streamable HTTP
13
- transport. These tool definitions are not written here: they are **discovered at
14
- runtime** with `tools/list`, so their number and shape are whatever Linear serves.
15
-
16
- > Status: current release **0.1.0**, published as the public GitHub repository
17
- > `KB1SLN-Labs/dsh-linear` and on npm as `@kb1slnlabs/dsh-linear`. The offline suites below are mock-tested, **except** `test-loader.mjs`
18
- > and `test-image-runtime.mjs`, which run against the real host services — the plugin
19
- > loader plus the real `ToolRuntime`/`SystemPrompt` — rather than stubs; the live checks
20
- > this repo has run are the introspection guard in `test.js` and a real discovery of the
21
- > hosted MCP tool set — see [Testing](#testing) for exactly what is and is not proven.
7
+ It exposes **one tool surface**: 26 hand-written tools over the **Linear GraphQL
8
+ API** (`https://api.linear.app/graphql`), authenticated with a personal API token.
9
+
10
+ **There is no MCP in this package — at all.** It does not host an MCP server, it
11
+ does not act as an MCP client, it never connects to `mcp.linear.app`, and it has
12
+ no MCP configuration, dependency, or test. The 26 `linear_*` tools below are the
13
+ entire surface; nothing else is registered under any condition.
14
+
15
+ > **Scope honesty:** 25 of these tools are a fixed, hand-modelled subset of what
16
+ > the Linear GraphQL API can do. They cover issues, comments, relations, teams,
17
+ > states, labels, projects, cycles and users — and nothing beyond that (no
18
+ > attachments, no documents/projects search, no webhooks, no auth/user-management
19
+ > administration, no bulk operations). The set is not auto-discovered and does not
20
+ > grow on its own: if you need a capability outside the table below, it is not
21
+ > there. The 26th tool, [`linear_graphql`](#direct-graphql-escape-hatch-linear_graphql),
22
+ > is the deliberate escape hatch for exactly that case — but what it can reach is
23
+ > bounded by Linear's own API and by your key's permissions, so it extends
24
+ > coverage, it does not guarantee parity. See [Limitations](#limitations).
25
+
26
+ > Status: this is **version 0.2.0** of the standalone implementation — the
27
+ > GitHub release for it is
28
+ > [KB1SLN-Labs/dsh-linear/releases/tag/v0.2.0](https://github.com/KB1SLN-Labs/dsh-linear/releases/tag/v0.2.0),
29
+ > and the package is published on npm as `@kb1slnlabs/dsh-linear`. The earlier
30
+ > **0.1.0** release, also on the public GitHub repository `KB1SLN-Labs/dsh-linear`
31
+ > and on npm, shipped with an MCP bridge to Linear's hosted MCP server; that
32
+ > bridge is gone from 0.2.0 (see **There is no MCP in this package — at all.**
33
+ > above and [Limitations](#limitations)), so do not treat 0.1.0 as this package.
34
+ > The offline suite described under
35
+ > [Testing](#testing) is mock-tested (loopback only), except
36
+ > that it runs a live schema check when `LINEAR_API_KEY` is set;
37
+ > `test-loader.mjs` is a separate loader smoke test, not part of `npm test`.
22
38
 
23
39
  ## Tools
24
40
 
25
- ### GraphQL tools (`linear_*`) — 20 registrations
41
+ ### GraphQL tools (`linear_*`) — 26 registrations
26
42
 
27
43
  | Tool | Purpose |
28
44
  |---|---|
29
- | `linear_issue_list` | List issues (filter by team key, state name, free-text search; `limit` default 20, max 50) |
45
+ | `linear_issue_list` | List issues (filter by team key, state name, free-text search; `limit` default 20, max 50; cursor pagination via `after` / `pageInfo`) |
30
46
  | `linear_issue_get` | One issue by id or identifier (e.g. `APT-123`), incl. relations |
31
47
  | `linear_issue_create` | Create an issue in a team |
32
48
  | `linear_issue_update` | Update title, description, state, assignee, labels, priority |
49
+ | `linear_issue_archive` | Archive an issue by id or identifier |
50
+ | `linear_issue_unarchive` | Unarchive a previously archived issue by id or identifier |
33
51
  | `linear_comment_add` | Add a comment to an issue |
34
- | `linear_comment_list` | List comments on an issue (newest first) |
52
+ | `linear_comment_update` | Update a comment body (comment UUID from `linear_comment_list`) |
53
+ | `linear_comment_delete` | Delete a comment (comment UUID) — destructive |
54
+ | `linear_comment_list` | List comments on an issue (newest first; cursor pagination via `after` / `pageInfo`) |
35
55
  | `linear_relation_add` | Link a related issue (`related` / `blockedBy` / `blocks` / `parent` / `child`) |
36
- | `linear_team_list` | List teams |
56
+ | `linear_team_list` | List teams (`after` / `pageInfo`; page size fixed at 100, no `limit` argument) |
37
57
  | `linear_team_get` | One team by id (UUID) or key |
38
- | `linear_state_list` | List workflow states for a team |
39
- | `linear_label_list` | List labels for a team |
58
+ | `linear_state_list` | List workflow states for a team (cursor pagination via `after` / `pageInfo`) |
59
+ | `linear_label_list` | List labels for a team (cursor pagination via `after` / `pageInfo`) |
40
60
  | `linear_label_get` | One label by id (UUID) |
41
61
  | `linear_label_create` | Create a label for a team |
42
- | `linear_project_list` | List projects |
62
+ | `linear_project_list` | List projects (`limit` default 20, max 50; cursor pagination via `after` / `pageInfo`) |
43
63
  | `linear_project_get` | One project by id (UUID) |
44
64
  | `linear_project_create` | Create a project (optional team keys) |
45
- | `linear_cycle_list` | List cycles for a team |
65
+ | `linear_project_update` | Update a project: name, description, content, icon, color, team keys, start/target date, priority |
66
+ | `linear_cycle_list` | List cycles for a team (`limit` default 20, max 50; cursor pagination via `after` / `pageInfo`) |
46
67
  | `linear_cycle_get` | One cycle by id (UUID) |
47
- | `linear_user_list` | List users in the workspace |
68
+ | `linear_user_list` | List users in the workspace (`limit` default 20, max 50; cursor pagination via `after` / `pageInfo`) |
48
69
  | `linear_user_get` | One user by id (UUID) or email |
49
-
50
- These are the "legacy" surface in the sense that they predate the MCP bridge and
51
- are hand-modelled against Linear's GraphQL schema. They remain the default and are
52
- the only tools that work with no MCP token configured.
53
-
54
- ### MCP parity tools (`linear_mcp_*`) — discovered, not declared
55
-
56
- There is **no fixed list** of these tools in this repo. On connect the bridge pages
57
- through `tools/list` and registers one harness tool per discovered MCP tool:
58
-
59
- - public name = `linear_mcp_<rawName>` when it fits the function-name contract
60
- (≤64 chars, `[A-Za-z0-9_-]`); otherwise a lossy normalization plus a 12-hex
61
- SHA-256 hash of the identity, so distinct MCP tools never collapse onto one name.
62
- Names are a pure function of the discovered raw name, so they are stable across
63
- reconnects.
64
- - calls are forwarded with `tools/call` using the **raw wire name**, never the public name.
65
- - the discovered `inputSchema` is passed through unchanged; an advertised
66
- `outputSchema` is kept only if the harness supports that JSON Schema vocabulary,
67
- otherwise the output is unconstrained.
68
- - `notifications/tools/list_changed` triggers re-discovery and an atomic generation
69
- swap (the full new list is fetched first; only then are the old registrations disposed).
70
+ | `linear_graphql` | **Escape hatch** — send an arbitrary GraphQL document (query *or* mutation) straight to the Linear API; see below |
71
+
72
+ Cursor pagination is live on **all eight** list tools — `linear_issue_list`,
73
+ `linear_comment_list`, `linear_team_list`, `linear_state_list`,
74
+ `linear_label_list`, `linear_project_list`, `linear_cycle_list` and
75
+ `linear_user_list`. Each accepts an optional `after` cursor (the previous page's
76
+ `pageInfo.endCursor`) and returns `pageInfo { hasNextPage, endCursor }`; the
77
+ cursor is always sent as a GraphQL variable (`$after: String`), never
78
+ interpolated into the document, and a server that reports no `pageInfo` yields
79
+ `pageInfo: null` rather than an invented value. Page sizes are unchanged: `limit`
80
+ defaults to 20 and is clamped to a maximum of **50** wherever it exists
81
+ (`issue_list`, `comment_list`, `project_list`, `cycle_list`, `user_list`), and
82
+ `linear_team_list` has no `limit` argument at all — its page size stays fixed at
83
+ 100. `state_list` and `label_list` likewise take no `limit`; they are paginated
84
+ purely through `after`. See [Limitations](#limitations).
85
+
86
+ ### Direct GraphQL escape hatch (`linear_graphql`)
87
+
88
+ One tool is not a hand-modelled endpoint. `linear_graphql` takes the caller's own
89
+ GraphQL document and forwards it verbatim to the **same** fixed endpoint
90
+ (`https://api.linear.app/graphql`) with the **same** configured key, through the
91
+ same client and the same `{ "error": … }` wrapper every other tool uses. It adds
92
+ no endpoint, no dependency, no setting, and no credential of its own.
93
+
94
+ | Argument | Type | Notes |
95
+ |---|---|---|
96
+ | `query` | string, **required** | The GraphQL document — a query or a mutation. Must be non-blank. Sent verbatim; the tool adds, wraps, or normalises nothing. |
97
+ | `variables` | object, optional | GraphQL variable values keyed by name (e.g. `{ "id": "<uuid>" }`). Prefer variables over splicing values into the document text. |
98
+
99
+ The result is the server's raw `data`, JSON-stringified as `{ "data": … }`: no
100
+ projection, no field curation, no `pageInfo` normalisation — the document you
101
+ write decides the shape, including whether you ask for `pageInfo { hasNextPage
102
+ endCursor }` yourself.
103
+
104
+ Both queries and mutations are allowed, and that is the point. A mutation
105
+ through this tool really writes, and what it may read or change is governed
106
+ entirely by the permissions attached to your Linear API key — the same authority
107
+ every other tool here already runs under. There is no extra allow-list, no
108
+ read-only mode, and no dry-run: if your key can do it and the Linear schema
109
+ accepts the document, it happens.
110
+
111
+ **Coverage here depends on Linear's API, not on this package.** The escape hatch
112
+ can only reach what `api.linear.app` currently exposes to your key — fields,
113
+ types, and mutations Linear defines today, subject to their own deprecations,
114
+ rate limits, and workspace scoping. It is *not* named-tool parity: there are no
115
+ guarantees that a capability exists, that it stays stable across Linear releases,
116
+ that the plugin's live schema guard covers it, or that the offline mock suite
117
+ models it (the suite proves the plumbing — document pass-through, variables as
118
+ variables, and error handling — not any specific user-written document). The 25
119
+ named tools carry curated inputs, identifier resolution, and projected outputs;
120
+ this one deliberately carries none of that.
70
121
 
71
122
  ## Configuration
72
123
 
73
- Two independent credentials, resolved by the plugin at apply time:
124
+ One credential, resolved by the plugin at apply time:
74
125
 
75
126
  | Setting | Env var | Plugin config | Used for | Header sent |
76
127
  |---|---|---|---|---|
77
- | GraphQL key | `LINEAR_API_KEY` | `apiKey` | `linear_*` tools | `Authorization: <raw key>` — **not** `Bearer` |
78
- | MCP bearer token | `LINEAR_MCP_TOKEN` | `mcp.token` | `linear_mcp_*` bridge | `Authorization: Bearer <token>` |
79
-
80
- The MCP token falls back in this order: `mcp.token` → `LINEAR_MCP_TOKEN` →
81
- `apiKey` → `LINEAR_API_KEY`. So a single Linear API key can drive both surfaces.
82
- The token is placed **only** in the `Authorization` header of requests to `mcp.url`;
83
- it never appears in a tool name, description, schema, or log line (error text is
84
- passed through a secret-redaction guard first).
128
+ | GraphQL key | `LINEAR_API_KEY` | `apiKey` | all 26 `linear_*` tools, incl. `linear_graphql` | `Authorization: <raw key>` — **not** `Bearer` |
85
129
 
86
130
  ```powershell
87
131
  # PowerShell (current session)
88
- $env:LINEAR_API_KEY = "li_api_..."
89
- $env:LINEAR_MCP_TOKEN = "..." # optional; falls back to LINEAR_API_KEY
132
+ $env:LINEAR_API_KEY = "li_api_..."
90
133
 
91
134
  # or persist for the user
92
135
  setx LINEAR_API_KEY "li_api_..."
93
136
  ```
94
137
 
95
138
  The `dsh` launcher inherits the parent environment, so any harness started after a
96
- variable is set will see it.
97
-
98
- ### MCP bridge options (plugin config)
99
-
100
- ```yaml
101
- # in a profile's cordis.patch.yml
102
- - insert:
103
- - id: linear
104
- name: '@kb1slnlabs/dsh-linear' # or a local path — see Install
105
- config:
106
- apiKey: li_api_... # optional, falls back to LINEAR_API_KEY
107
- mcp:
108
- token: '' # optional; env fallbacks as above
109
- url: https://mcp.linear.app/mcp
110
- toolCallTimeoutMs: 60000 # per-call timeout
111
- reconnect:
112
- enabled: true
113
- initialDelayMs: 500 # bounded exponential backoff
114
- maxDelayMs: 30000
115
- maxAttempts: 10
139
+ variable is set will see it. A config `apiKey` in the patch row wins over the env
140
+ var; when it is empty or absent, the env var is used. With neither set, every tool
141
+ returns `{ "error": "Linear API key not configured: …" }` instead of throwing.
142
+
143
+ That is the whole config object:
144
+
145
+ ```ts
146
+ const Config = z.object({ apiKey: z.string().default('') })
116
147
  ```
117
148
 
149
+ There are no other settings and no other environment variables. The plugin reads
150
+ no MCP-related setting, opens no non-GraphQL connection, and an `mcp` block left in
151
+ an old profile's config is simply ignored (the schema declares no such field).
152
+
118
153
  The repo's own `cordis.patch.yml` inserts the plugin with **no config at all** — it
119
154
  relies on the environment variables.
120
155
 
121
- `LINEAR_MCP_DISABLED=1` turns the bridge off entirely. The bridge is **fail-soft**:
122
- with no token, an unloadable MCP SDK, or a host context that lacks `ctx.effect`, the
123
- GraphQL suite still registers and the bridge simply stays off (a warning is logged).
124
- After `maxAttempts` consecutive failures the MCP tools are unregistered until the
125
- plugin reloads or the host restarts.
126
-
127
156
  Override env vars used to pin dependency resolution (mainly for offline tests):
128
- `DSH_LINEAR_TOOLS`, `DSH_LINEAR_SCHEMA`, `DSH_LINEAR_MCP_SDK`,
129
- `DSH_LINEAR_MCP_SDK_TRANSPORT`, `DSH_LINEAR_MCP_SDK_TYPES`, `DSH_LINEAR_MCP_SDK_ZOD`.
130
-
131
- ### What MCP result content becomes model-visible
132
-
133
- The harness model vocabulary carries images as **durable attachment references**, not
134
- inline bytes, so a `linear_mcp_*` result is projected before the model sees it:
135
-
136
- - An `image` block is admitted **only when the current model route declares image
137
- input** — positive proof via the host's `llm.resolveModelInfo` for the exact
138
- provider/model that will read this result, plus a mounted `attachments` service. The
139
- bytes then go through the host's own admission pipeline (`attachments.saveImages`),
140
- which owns the count / aggregate-byte / media-type limits.
141
- - Anything else — a model that does not declare image input, no mounted store, an
142
- unresolvable route, an invalid block (non-canonical base64, or a media type outside
143
- PNG/JPEG/WebP/GIF), or an admission rejection (`TOO_MANY_IMAGES`, `IMAGE_TOO_LARGE`,
144
- …) — yields a text diagnostic in place of that image:
145
- `[image unavailable: <media type>; <reason>; raw image data remains available to
146
- programmatic callers]`. One invalid block falls back every image in the same result.
147
- - **The canonical raw result is never rewritten.** Base64 stays in `result.value` for
148
- programmatic callers either way; only the model-visible content carries references or
149
- diagnostics. Text and error behaviour are unchanged.
150
- - `audio`, embedded `resource`, and `resource_link` blocks stay **text placeholders** —
151
- they are not admitted as attachments (`resource_link` becomes
152
- `Resource link: <name> (<uri>)`).
157
+ `DSH_LINEAR_TOOLS`, `DSH_LINEAR_SCHEMA`.
153
158
 
154
159
  ## Install
155
160
 
156
- Release **0.1.0** is published on the public GitHub repository
157
- `KB1SLN-Labs/dsh-linear` and on npm as `@kb1slnlabs/dsh-linear`. Four install routes:
161
+ This is **version 0.2.0** of the standalone implementation; its release is
162
+ [KB1SLN-Labs/dsh-linear/releases/tag/v0.2.0](https://github.com/KB1SLN-Labs/dsh-linear/releases/tag/v0.2.0),
163
+ and the tarball attached to that release — `kb1slnlabs-dsh-linear.tgz` — is the
164
+ pinned artifact for route 4 below. Four install routes:
158
165
 
159
166
  ```powershell
160
167
  # 0. install this plugin's own dependencies (local checkout route only)
@@ -169,9 +176,9 @@ dsh plugin --profile web add github:KB1SLN-Labs/dsh-linear
169
176
  # 3. from npm, by package name (latest published version)
170
177
  dsh plugin --profile web add @kb1slnlabs/dsh-linear
171
178
 
172
- # 4. version-pinned release asset — download the tarball attached to the v0.1.0
179
+ # 4. version-pinned release asset — download the tarball attached to the v0.2.0
173
180
  # GitHub release, then install it from disk:
174
- # https://github.com/KB1SLN-Labs/dsh-linear/releases/download/v0.1.0/kb1slnlabs-dsh-linear.tgz
181
+ # https://github.com/KB1SLN-Labs/dsh-linear/releases/download/v0.2.0/kb1slnlabs-dsh-linear.tgz
175
182
  dsh plugin --profile web add file:./kb1slnlabs-dsh-linear.tgz
176
183
  ```
177
184
 
@@ -179,11 +186,11 @@ Route 1 (`link:`) points pnpm at the directory rather than copying it, so the pr
179
186
  picks up edits in place. Use an **absolute** path — that is what this repo's own install
180
187
  used, and it is what makes the spec unambiguous regardless of where `dsh plugin` runs
181
188
  from. Routes 2–4 install a copy instead, so later source edits do not leak into the
182
- profile. Only route 4 pins an exact version: `kb1slnlabs-dsh-linear.tgz` is the release
183
- asset attached to the v0.1.0 GitHub release — a fixed name renamed from the output of
189
+ profile. Only route 4 pins an exact version: `kb1slnlabs-dsh-linear.tgz` is the fixed
190
+ release-asset name for the v0.2.0 GitHub release — renamed from the output of
184
191
  `npm pack`, not the filename npm itself generates. Route 2 follows the repository's
185
192
  default branch and route 3 follows npm's `latest`, so neither is guaranteed to install
186
- 0.1.0 once newer work or releases exist; use route 4 when reproducibility matters.
193
+ 0.2.0 once newer work or releases exist; use route 4 when reproducibility matters.
187
194
 
188
195
  The clean scratch profile behind the verification described under
189
196
  [Testing](#testing) was installed through the **DSH plugin manager**, not by hand.
@@ -195,13 +202,12 @@ To add it as a plain dependency instead of a link, use `file:<absolute-path>` or
195
202
  packaged tarball (`npm pack`, then `dsh plugin --profile web add file:<path-to-tgz>`) —
196
203
  that is how the scratch profile used by [Testing](#testing) was built.
197
204
 
198
- Peer dependencies `@deepseek-ai/dsh-tools` and `@deepseek-ai/cordis`, plus
199
- `@modelcontextprotocol/sdk` and `zod`, normally resolve from the **dsh install's own**
200
- `node_modules`. The plugin resolves each dependency as a bare specifier first, then
201
- from an anchor derived portably from `process.execPath` (the npm/pnpm global prefix),
202
- then `%APPDATA%/npm/node_modules` on Windows or `/usr/local/lib/node_modules` /
203
- `/usr/lib/node_modules` on POSIX. The MCP SDK and its zod must come from one and the
204
- same tree — a partial tree pins nothing.
205
+ Peer dependencies `@deepseek-ai/dsh-tools` and `@deepseek-ai/cordis`, plus the
206
+ `@deepseek-ai/schemastery` this package depends on, normally resolve from the **dsh
207
+ install's own** `node_modules`. The plugin resolves each dependency as a bare specifier
208
+ first, then from an anchor derived portably from `process.execPath` (the npm/pnpm global
209
+ prefix), then `%APPDATA%/npm/node_modules` on Windows or `/usr/local/lib/node_modules` /
210
+ `/usr/lib/node_modules` on POSIX.
205
211
 
206
212
  The `@deepseek-ai/dsh-tools` this package pins (`0.1.5-rc.2`) was verified against host
207
213
  **0.2.1-rc.3**. In a base-only scratch profile it can log **missing-peer warnings** —
@@ -210,42 +216,49 @@ plugin resolves its own peer at all. Do not read such a warning as a failure.
210
216
 
211
217
  ## Testing
212
218
 
213
- Four suites: the mock halves are **offline** (loopback mock servers only; no real
214
- credential), but `test.js` additionally runs a live schema check against
215
- `api.linear.app` whenever `LINEAR_API_KEY` is set — see below, and note that
216
- `test-loader.mjs` and `test-image-runtime.mjs` are not mocks at all.
217
-
218
219
  ```
219
- node test.js # GraphQL half: module surface, 20 tool registrations,
220
- # 40 GraphQL calls against a mock that mirrors the real
221
- # Linear schema (flat UUID inputs, plural-field resolution,
222
- # auth header), plus negative cases; and, when
223
- # LINEAR_API_KEY is set, the live introspection guard
224
- node test-mcp-bridge.js # MCP half: naming contract, paginated discovery, Bearer
225
- # auth with no token leak, raw-name forwarding, isError
226
- # handling, list_changed re-sync, disposal, fail-soft paths,
227
- # and image-result projection/admission through the bridge
228
- node test-loader.mjs # loader half — needs two environment variables, see below
229
- node test-image-runtime.mjs # image half: real ToolRuntime + SystemPrompt, a stubbed
230
- # llm route that declares image input and a stubbed
231
- # attachment store; asserts an MCP image result comes back
232
- # as a model-visible image block while the raw base64 stays
233
- # in result.value. No network, no profile, no env vars.
220
+ node test.js # module surface (name/inject/apply/Config), 26 tool
221
+ # registrations, an `mcp` config proven inert (no extra
222
+ # tools, no log lines, no effects, no connection), ~183
223
+ # GraphQL calls against a loopback mock that mirrors the
224
+ # real Linear schema (flat UUID inputs, plural-field
225
+ # resolution, raw-key auth header) — the exact call count
226
+ # is printed by the suite rather than asserted — cursor
227
+ # pagination across all eight direct list tools
228
+ # (issue, comment, team, state, label, project, cycle,
229
+ # user), the five direct
230
+ # write tools (project update, comment update/delete, issue
231
+ # archive/unarchive) incl. unresolved identifiers and
232
+ # success:false payloads, the linear_graphql escape hatch
233
+ # (query and mutation documents, variables as variables,
234
+ # invalid arguments, GraphQL and HTTP errors), negative
235
+ # cases; and, when LINEAR_API_KEY is set, the live
236
+ # introspection guard
234
237
  ```
235
238
 
236
- ### `test-loader.mjs` — what it runs and what it needs
239
+ `npm test` runs exactly that. It needs no key and makes no network call **except**
240
+ the introspection guard below, which runs only when `LINEAR_API_KEY` is present.
241
+
242
+ `test.js` additionally carries a **live introspection guard** that runs only when
243
+ `LINEAR_API_KEY` is set: it introspects the real Linear schema and asserts every input
244
+ field, query field, mutation field, mutation argument, and payload field the plugin emits
245
+ actually exists. Without a key it prints
246
+ `skip - introspection guard`. So in an environment with no `LINEAR_API_KEY` the suite
247
+ passes with that guard skipped, but **with a key set, `test.js` does use live network** —
248
+ it sends a real introspection request to `https://api.linear.app/graphql`, and a schema
249
+ mismatch makes the run throw rather than skip.
250
+
251
+ ### `test-loader.mjs` — optional loader smoke test (not part of `npm test`)
237
252
 
238
- Unlike the mock suites, `test-loader.mjs` exercises the **real plugin loader and the real
239
- host tool runtime**: it boots a Cordis `Context`, attaches the host's real
240
- `SystemPrompt` and `ToolRuntime` services (imported from the dsh installation, not
241
- stubbed), runs `cordis-plugin-loader`, installs this plugin into that context through
242
- the profile, asserts the entry got a live fiber and that exactly the 20 `linear_*`
243
- names are known to the runtime — then disposes the fiber and asserts all 20 are gone.
244
- It carries a 15-second step deadline: before each phase it checks whether 15 seconds
245
- have elapsed and, if so, fails with `15 second bound exceeded at: <label>`. That
246
- deadline is a progress budget between steps, **not** a hard timeout — it cannot
247
- interrupt an asynchronous call that never settles, so a hung import or await stalls the
248
- run instead of being cut off at 15 seconds.
253
+ Exercises the **real plugin loader and the real host tool runtime**: it boots a Cordis
254
+ `Context`, attaches the host's real `SystemPrompt` and `ToolRuntime` services (imported
255
+ from the dsh installation, not stubbed), runs `cordis-plugin-loader`, installs this
256
+ plugin into that context through the profile, asserts the entry got a live fiber and
257
+ that exactly the 26 `linear_*` names are known to the runtime — then disposes the fiber
258
+ and asserts all 26 are gone. It carries a 15-second step deadline: before each phase it
259
+ checks whether 15 seconds have elapsed and, if so, fails with
260
+ `15 second bound exceeded at: <label>`. That deadline is a progress budget between steps,
261
+ **not** a hard timeout — it cannot interrupt an asynchronous call that never settles.
249
262
 
250
263
  Both variables are **required**; a missing or malformed one fails immediately with an
251
264
  actionable message rather than an import error:
@@ -266,71 +279,99 @@ node test-loader.mjs
266
279
 
267
280
  The suite deletes `LINEAR_API_KEY`, `LINEAR_MCP_TOKEN`, `LINEAR_APIKEY` and
268
281
  `LINEAR_KEY` from its own environment before loading, so the plugin it installs cannot
269
- pick up a live credential and the bridge stays off. That clearing is scoped to this
270
- process — other suites behave differently: `test.js` deliberately runs live against
271
- `api.linear.app` when a real key is present (see below).
282
+ pick up a live credential. (It clears the MCP variable for cleanliness only — this
283
+ plugin no longer reads it.) That clearing is scoped to this process; `test.js` behaves
284
+ differently, deliberately running live against `api.linear.app` when a real key is
285
+ present (see above).
272
286
 
273
287
  **What this does not cover:** it is not a full headless boot. No LLM, session log,
274
288
  filesystem sandbox, or app entry tree is started — only the loader plus those two host
275
- services inside a bare `Context`. And it never contacts Linear: no GraphQL call and no
276
- connection to the hosted MCP server are made, so the discovered `linear_mcp_*` surface
277
- is not exercised here. The scratch profile it runs against is a throwaway directory,
278
- not your day-to-day profile.
279
-
280
- `test.js` additionally carries a **live introspection guard** that runs only when
281
- `LINEAR_API_KEY` is set: it introspects the real Linear schema and asserts every input
282
- field and query field the plugin emits actually exists. Without a key it prints
283
- `skip - introspection guard`. So in an environment with no `LINEAR_API_KEY` the suite
284
- passes with that guard skipped, but **with a key set, `test.js` does use live network** —
285
- it sends a real introspection request to `https://api.linear.app/graphql`, and a schema
286
- mismatch makes the run throw rather than skip.
287
-
288
- ### `test-image-runtime.mjs` — what it runs and what it needs
289
-
290
- No environment variables and no profile: it builds a bare Cordis `Context`, attaches the
291
- real `SystemPrompt` and `ToolRuntime` (mode `native`), registers one hand-built MCP
292
- definition, executes it through `tools.execute`, and asserts the finalized content is
293
- `[text, image-with-attachment-ref]` while `result.value` still carries the raw base64.
294
- The `llm` route (`inputModalities: ['image']`) and the attachment store are stubs — so
295
- this proves the bridge's admission **decision** and projection against the real runtime,
296
- not a real model's image support or the host's real byte/count limits. It needs the same
297
- peer resolution as the plugin itself (a dsh installation to import `cordis`,
298
- `dsh-tools`, and `dsh-system-prompt` from), and it can emit the missing-peer warnings
299
- described under [Install](#install).
289
+ services inside a bare `Context`. And it never contacts Linear: no GraphQL call is made.
290
+ The scratch profile it runs against is a throwaway directory, not your day-to-day
291
+ profile.
300
292
 
301
293
  ### What is verified vs. what is not
302
294
 
303
- - **Verified (mock-tested):** what `test.js` and `test-mcp-bridge.js` assert —
304
- registration, GraphQL query/mutation shape, the auth header forms, identifier
305
- resolution, error payloads, MCP discovery/naming/forwarding/reconnect/disposal
306
- lifecycle, and image admission refusals against stubbed host services.
295
+ - **Verified (mock-tested):** what `test.js` asserts — registration of exactly the 26
296
+ tools, GraphQL query/mutation shape, the raw-key auth header form, identifier
297
+ resolution, `{ error }` payloads, cursor pagination on all eight direct list
298
+ tools (first-page `pageInfo`, `after` passed only as a `$after` variable, blank
299
+ and non-string cursors treated as no cursor, a bad cursor surfacing as a clean
300
+ error, and a pageInfo-less server normalising to `null`), page sizes unchanged
301
+ (`limit` default 20 clamped to max 50; `team_list` still `first: 100` with no
302
+ `limit` parameter),
303
+ the five direct write tools (`projectUpdate`, `commentUpdate`, `commentDelete`,
304
+ `issueArchive`, `issueUnarchive`) — including that an unresolved project name or issue
305
+ identifier never reaches a mutation and that a `success: false` payload surfaces as a
306
+ clean error — the `linear_graphql` escape hatch (a query document and a mutation
307
+ document forwarded verbatim, caller variables arriving as variables rather than query
308
+ text, blank/non-string/missing `query` and non-object `variables` rejected before any
309
+ request, GraphQL `errors[]` and non-2xx responses surfacing through the same
310
+ `{ error }` wrapper; all against loopback, so no real write happens) — and that an
311
+ `mcp` config block is inert (registers nothing, logs nothing, calls no effect).
307
312
  - **Verified against real host services (not mocks):** `test-loader.mjs` loads this
308
- package through the real plugin loader into a real `ToolRuntime`, and
309
- `test-image-runtime.mjs` finalizes an MCP image result through the real
310
- `ToolRuntime`/`SystemPrompt`. The scratch profile behind that run was installed via
311
- the **DSH plugin manager** and verified on **desktop runtime 0.2.1-rc.3** with the
312
- Cordis loader plus `ToolRuntime` — a bare host context, **not** a full desktop app
313
- boot: no LLM turn, session log, sandbox, or app entry tree was started.
313
+ package through the real plugin loader into a real `ToolRuntime`. The scratch profile
314
+ behind that run was installed via the **DSH plugin manager** and verified on **desktop
315
+ runtime 0.2.1-rc.3** with the Cordis loader plus `ToolRuntime` — a bare host context,
316
+ **not** a full desktop app boot: no LLM turn, session log, sandbox, or app entry tree
317
+ was started.
314
318
  - **Verified (CLI install smoke, separate from the line above):** a clean scratch
315
319
  profile based on the **shipped headless template** took the **packed** plugin through
316
320
  `dsh plugin`, and `dsh --profile <scratch> --help` exited **0** on **Windows CLI
317
321
  0.1.5-rc.2**. That is an install-and-launch smoke check only: no model turn and no
318
322
  full desktop GUI boot were performed, and it says nothing about the loader/`ToolRuntime`
319
323
  run above, which was on desktop runtime 0.2.1-rc.3.
320
- - **Verified against live services:** the hosted MCP bridge has been checked against
321
- the real `https://mcp.linear.app/mcp` endpoint — a real run through the DSH
322
- `ToolRuntime`, installing this package from a clean scratch package, discovered
323
- **68** `linear_mcp_*` tools. The live Linear GraphQL introspection guard in `test.js`
324
- has likewise run with a real key and passed. So the MCP tool count, names, and schemas
325
- are what Linear actually served at that time, not guesses.
324
+ - **Verified against live services:** the Linear GraphQL introspection guard in `test.js`
325
+ has run with a real key and passed — the schema fields this plugin emits, including the
326
+ five direct-write mutations, their arguments, and their payload shapes (`ProjectPayload`,
327
+ `CommentPayload`, `DeletePayload`, `IssueArchivePayload`), exist.
326
328
  - **Not verified:** no run in this repo's check has exercised a real `linear_*` **write**
327
329
  end-to-end against `api.linear.app` — the GraphQL half is proven against a mock that
328
330
  mirrors the introspected schema (plus the live schema-shape guard above), not against
329
- real issue mutations. Image admission likewise has **not** been exercised with a real
330
- model route or a real attachment store — only with declared-modalities and stored-ref
331
- stand-ins. Nothing here was run on more than that one Windows host/runtime, so treat
332
- other platforms as untested. Linear's hosted MCP tool set can also change server-side,
333
- so re-discover it with your own key before relying on the exact count or shapes above.
331
+ real issue mutations. Nothing here was run on more than one Windows host/runtime, so
332
+ treat other platforms as untested.
333
+
334
+ ## Limitations
335
+
336
+ Stated plainly, because 25 of the tools above are a fixed hand-modelled subset:
337
+
338
+ - **Pagination covers the list tools but not the page sizes.** All eight list
339
+ tools paginate (`after` + `pageInfo`), so nothing is cut off *silently* — you can
340
+ always continue from `pageInfo.endCursor`. What stays capped is the page size:
341
+ `limit` defaults to 20 and never exceeds **50** (`issue_list`, `comment_list`,
342
+ `project_list`, `cycle_list`, `user_list`), while `team_list` sends a fixed
343
+ `first: 100` and takes no `limit`, and `state_list`/`label_list` take no `limit`
344
+ at all. A single call therefore returns only one capped page; reading everything
345
+ means following the cursor yourself. The `_get` tools are not paginated — they
346
+ fetch one entity.
347
+ - **No search beyond free-text on issues.** `linear_issue_list`'s `search` matches
348
+ title/description case-insensitively; there is no project, document, or cycle search.
349
+ - **Create/update coverage is narrow but no longer empty.** Issues, comments, relations,
350
+ labels and projects can be created; issues and projects can be updated, comments edited
351
+ and deleted, and issues archived/unarchived. Nothing else can: there is no team or state
352
+ management, no cycle create, no project delete, no bulk operation, no attachment upload,
353
+ and no user administration. Among the named tools, `linear_comment_delete` is the
354
+ only destructive one and has no undo — but it is not the only destructive thing
355
+ this package can do: `linear_graphql` runs any mutation your key permits, so
356
+ operations outside the named set (deletions included) are reachable through it.
357
+ - **`linear_comment_add` targets issues only** — it sends `CommentCreateInput.issueId`,
358
+ so it cannot comment on a project, update, or document.
359
+ - **Fields are projected, not passed through.** The 25 named tools return the fixed
360
+ field sets in the GraphQL fragments (e.g. an issue's state as a name string), so
361
+ anything not in those fragments is simply absent from the result. `linear_graphql`
362
+ passes `data` through raw instead — which is why its coverage is whatever Linear's
363
+ API exposes, and nothing more.
364
+ - **`linear_graphql` is uncurated by design.** It validates that the document is a
365
+ non-blank string and that `variables` is an object; it does not validate the
366
+ document against the Linear schema, does not restrict mutations, and does not
367
+ rewrite anything. A malformed document fails on Linear's side as an `{ error }`
368
+ payload, and a valid one executes with your key's full permissions — including
369
+ destructive operations (deletes, archives, unarchives, and anything else the
370
+ Linear schema offers) that no named tool models and that this package does not
371
+ gate. Reach for it when the named tools fall short,
372
+ not instead of them.
373
+ - **One workspace per key.** A Linear API key is workspace-scoped; there is no
374
+ multi-workspace switching.
334
375
 
335
376
  ## Notes
336
377
 
@@ -349,9 +390,20 @@ So before any write, the plugin resolves human identifiers to UUIDs through the
349
390
 
350
391
  - a team key/name → `teams(filter: { key: { eq: … } })` → `teamId`
351
392
  - a state name → `workflowStates(filter: { team: { id: { eq: <teamId> } }, name: { eq: … } })` → `stateId`
393
+ - a project name → `projects(filter: { name: { eq: … } })` → the mutation's top-level `id`
352
394
 
353
395
  If a team key/name does not resolve to exactly one team, the tool fails with a clear
354
- `Team '<x>' not found` error instead of sending a malformed mutation. An issue
355
- identifier is resolved by splitting it into team key + number and querying
356
- `issues(filter: { team: { key: { eq: … } }, number: { eq: … } })`, since `IssueFilter`
357
- has no `identifier` field.
396
+ `Team '<x>' not found` error instead of sending a malformed mutation. A project name that
397
+ matches more than one project is refused the same way — the resolver will not pick one of
398
+ several candidates and update an ambiguous target; it reports that the name matched multiple
399
+ projects and asks for the project UUID. An issue identifier is resolved by splitting it into
400
+ team key + number and querying `issues(filter: { team: { key: { eq: … } }, number: { eq: … } })`,
401
+ since `IssueFilter` has no `identifier` field. That lookup is archived-blind like Linear's own
402
+ `issues` query, **except** for `linear_issue_unarchive`, which adds `includeArchived: true` —
403
+ without it the one issue class that tool exists to fix would be invisible to its own lookup.
404
+
405
+ Note that the update/archive mutations do **not** put the target's UUID inside `input`:
406
+ `projectUpdate(id:, input:)` and `commentUpdate(id:, input:)` take it as a separate top-level
407
+ argument, and `commentDelete(id:)`, `issueArchive(id:)` and `issueUnarchive(id:)` take a bare
408
+ `id` at all — there is no `CommentDeleteInput` or archive-input type in the Linear schema. The
409
+ archive mutations return their issue under the payload field `entity`, not `issue`.