@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/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 **
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
>
|
|
17
|
-
>
|
|
18
|
-
>
|
|
19
|
-
>
|
|
20
|
-
>
|
|
21
|
-
>
|
|
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_*`) —
|
|
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
|
-
| `
|
|
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
|
-
| `
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
|
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
|
-
|
|
157
|
-
|
|
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.
|
|
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.
|
|
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
|
|
183
|
-
asset
|
|
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.
|
|
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
|
-
`@
|
|
200
|
-
`node_modules`. The plugin resolves each dependency as a bare specifier
|
|
201
|
-
from an anchor derived portably from `process.execPath` (the npm/pnpm global
|
|
202
|
-
then `%APPDATA%/npm/node_modules` on Windows or `/usr/local/lib/node_modules` /
|
|
203
|
-
`/usr/lib/node_modules` on POSIX.
|
|
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
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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
|
-
|
|
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
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
the
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
|
270
|
-
|
|
271
|
-
`api.linear.app` when a real key is
|
|
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
|
|
276
|
-
|
|
277
|
-
|
|
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`
|
|
304
|
-
|
|
305
|
-
resolution, error payloads,
|
|
306
|
-
|
|
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
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
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
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
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.
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
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.
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
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`.
|