@derive-to/mcp 0.1.0 → 0.4.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.
Files changed (5) hide show
  1. package/SKILL.md +73 -7
  2. package/package.json +13 -12
  3. package/src/client.ts +179 -27
  4. package/src/index.ts +490 -59
  5. package/LICENSE +0 -105
package/SKILL.md CHANGED
@@ -22,10 +22,10 @@ Your identity (agent name, workspace, role) is in the server instructions — th
22
22
  | Tool | Use |
23
23
  |---|---|
24
24
  | `list_artifacts` | Find: the artifacts in your workspace (short id, title, kind, version, visibility). Optional `query` filters by title. |
25
- | `read` | Read an artifact's content by short id. For a bundle, omit `section` for the outline or pass a `section` (page path) for one page; pass `version` to read history. |
26
- | `catch_up` | Start here on an artifact: its state in one call — what changed since `since_version`, the open/outdated comment threads, and version history. Pass `comments` (open/addressed/resolved/outdated) for that filtered feedback queue, or `response_format='detailed'` (with optional `since_version`/`to_version`) to fold in the exact line diff. |
27
- | `comment` | Leave feedback, reply (`reply_to` a thread id), anchor to a `quote`, and/or resolve/reopen (`set_state`). |
28
- | `publish` | Save a revision. `content` for a single file, `files` (path→content map) for a multi-page bundle. Omit `short_id` to create new (title required); pass it to add a version. `addresses` lists thread ids this revision resolves. |
25
+ | `read` | Read an artifact's content by short id, as **Markdown by default** (HTML is converted — headings, lists, tables, code fences; the styling noise is dropped). Omit `section` and a small doc/bundle returns whole; a **large one returns its outline first** (heading slugs for a single-file doc, page paths for a bundle) — call again with a `section` (a slug, a bundle page, `page.html#slug`, or `"*"` for the full clipped document). Pass `format:'html'` for the exact stored source (needed before publish `edits`) or `format:'text'` for flat visible text (what comment `quote`s anchor against). Pass `version` to read history. An image page in a bundle comes back as a real image, not garbage text. |
26
+ | `catch_up` | Start here on an artifact: its state in one call — what changed since `since_version`, the open/outdated comment threads, the `review` round state, and version history. Pass `comments` (open/addressed/resolved/outdated) for that filtered feedback queue, or `response_format='detailed'` (with optional `since_version`/`to_version`) to fold in a line diff — of the **readable Markdown form**, not raw HTML, so it shows what changed instead of tag noise. Waiting on a review? Pass `wait` (seconds, max 50) to long-poll: the call blocks until the human sends back / approves / comments — chain these instead of sleeping. |
27
+ | `comment` | Leave feedback, reply (`reply_to` a thread id), anchor to a `quote`, react (`react: "👍"` with `reply_to` — the loop's lightweight ack, landing on the thread's latest human comment), and/or resolve/reopen (`set_state`). |
28
+ | `publish` | Save a revision. `content` for a single file, `files` (path→content map) for a multi-page bundle, or **`edits`** (`[{old_str, new_str}]`) to revise part of a single-file artifact without resending it. Omit `short_id` to create new (title required); pass it to add a version. `addresses` lists thread ids this revision resolves; `request_review:true` opens a review round for your human. New artifacts land **private** by default (the human you act for owns the draft) — they promote via the share dialog, so don't pass a wider `visibility` unasked. The result's `opened_in_tab` says whether an open Derive tab caught the push; when false, open the `url` for the user if they should see it now. |
29
29
 
30
30
  ## Role decides: live publish vs proposal
31
31
 
@@ -46,6 +46,61 @@ human approves rather than live content.
46
46
  3. **Revise**, then **`comment`** (reply/resolve) and/or **`publish`** (pass `addresses`
47
47
  to resolve the threads this revision fixes) — same URL, a new version. Comment
48
48
  highlights re-anchor to the moved text.
49
+ 4. **Review rounds** (the /derive loop): publish with `request_review:true`, then
50
+ chain `catch_up(short_id, wait: 50)` — each call returns the moment the human
51
+ hits Send back / Approve (or ~50s pass). On `sent_back`, sweep ALL threads (any
52
+ author, anchored or not), ack every human comment FIRST
53
+ (`comment(reply_to, react:"👍")` at minimum), then revise and publish with
54
+ `addresses` + `request_review:true` for the next round. The human never
55
+ resolves threads — you settle thread state.
56
+
57
+ ## Reading big documents
58
+
59
+ `read` never hands you a wall of JSON-escaped HTML. A content-bearing response is
60
+ a small frontmatter header (short id, title, version, format, section, size, url)
61
+ followed by a blank line and the raw body — real newlines, greppable if a client
62
+ spills it to a file. When a document is large, `read` (no `section`) returns its
63
+ heading outline instead of the full text:
64
+
65
+ ```
66
+ { "sections": [
67
+ { "slug": "why-one-engine", "level": 2, "text": "Why: one engine", "chars": 2210 },
68
+ { "slug": "pr-6-the-fix", "level": 2, "text": "PR-6: the fix", "chars": 4812 }
69
+ ], "next": "Call read again with a section slug…" }
70
+ ```
71
+
72
+ Pull just the part you need: `read(short_id, { section: "pr-6-the-fix" })`. Pass
73
+ `section: "*"` to force the full (clipped) document when you genuinely need it all.
74
+
75
+ ## Edit, don't resend
76
+
77
+ Once you've read a section, revise it with `publish`'s `edits` instead of
78
+ resending the whole artifact:
79
+
80
+ ```
81
+ publish(short_id, { edits: [{ old_str: "exact text from the source", new_str: "replacement" }] })
82
+ ```
83
+
84
+ Each `old_str` must match **exactly once** in the current stored source — the
85
+ same contract as a coding Edit tool. If it doesn't match (or matches more than
86
+ once), nothing is applied and the error names which edit failed, so you add more
87
+ surrounding context and retry. For an HTML artifact, read with `format:'html'`
88
+ first — the Markdown view won't match raw source. Pass `base_version` (the
89
+ version you read) to fail fast instead of silently editing a version you never saw.
90
+
91
+ ## Mockups & screens
92
+
93
+ Reading Markdown by default doesn't flatten design work:
94
+
95
+ - **See it rendered**: every `read` response's frontmatter carries the artifact's
96
+ `url` — open it in a real browser (or a browser-automation tool) to view or
97
+ screenshot the live page.
98
+ - **See its structure/copy**: the default Markdown read.
99
+ - **See a screenshot inline**: reading an image page of a bundle (`section:
100
+ "shot.png"`) returns a real image content block, not decoded bytes as text.
101
+ - **Edit it**: `read(section, format:'html')` for the exact markup, then
102
+ `publish({ edits })` for a surgical change — a label, a color token — without
103
+ resending the whole design.
49
104
 
50
105
  ## Keep comments anchorable
51
106
 
@@ -59,7 +114,18 @@ wrong place.
59
114
 
60
115
  - Versions are immutable; `@vN` URLs never change. The viewer groups rapid
61
116
  same-author revisions into time-based sessions, but every revision is addressable.
62
- - Multi-page bundles are readable (`read` with a `section`, `catch_up`) and revisable
117
+ - Multi-page bundles are readable on both servers (`read` with a `section` a page
118
+ path, or `page.html#slug` for one heading's part; `catch_up`) but revisable only
63
119
  over the remote `/mcp` server via `publish` with a `files` map. Over the stdio
64
- `@derive-to/mcp` server, bundles are publish-via-remote/web only, and `comment` set_state
65
- takes a `comment_id`. Both servers expose the same 5 tools.
120
+ `@derive-to/mcp` server, bundles are publish-via-remote/web only, and `comment`
121
+ set_state takes a `comment_id`. `edits` (single-file only) works on both. Both
122
+ servers expose the same 5 tools.
123
+ - An older self-hosted Derive server that predates `format`/`section`/`outline`
124
+ responds to `read` with a note that it returned the full raw artifact instead —
125
+ the stdio client detects this from a missing response header and degrades rather
126
+ than silently misreading a section.
127
+ - The stdio server shares the machine's `derive login` — no token to paste. It acts as
128
+ your stored default account/workspace unless `DERIVE_ACCOUNT`/`DERIVE_WORKSPACE`
129
+ pin the project to a specific one (id or name; set in `.mcp.json`'s `env`). Still no
130
+ `whoami` tool, but a wrong pin fails loudly at startup rather than silently
131
+ targeting the wrong workspace.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@derive-to/mcp",
3
- "version": "0.1.0",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
5
  "description": "Stdio MCP server for Derive — list, read, catch up on, comment on, and publish artifacts on a Derive instance.",
6
6
  "keywords": [
@@ -37,24 +37,25 @@
37
37
  ".": "./src/index.ts",
38
38
  "./client": "./src/client.ts"
39
39
  },
40
+ "scripts": {
41
+ "start": "tsx src/index.ts",
42
+ "typecheck": "tsc --noEmit",
43
+ "test": "vitest run",
44
+ "test:coverage": "vitest run --coverage"
45
+ },
40
46
  "dependencies": {
47
+ "@derive-to/cli": "workspace:*",
41
48
  "@modelcontextprotocol/sdk": "^1.12.0",
42
49
  "tsx": "^4.19.0",
43
50
  "zod": "^4.4.3"
44
51
  },
45
52
  "devDependencies": {
53
+ "@derive/api": "workspace:*",
54
+ "@derive/db": "workspace:*",
55
+ "@derive/storage": "workspace:*",
46
56
  "@hono/node-server": "^2.0.5",
47
57
  "@types/node": "^25.9.3",
48
58
  "typescript": "^6.0.3",
49
- "vitest": "^4.1.9",
50
- "@derive/api": "0.1.0",
51
- "@derive/db": "0.1.0",
52
- "@derive/storage": "0.1.0"
53
- },
54
- "scripts": {
55
- "start": "tsx src/index.ts",
56
- "typecheck": "tsc --noEmit",
57
- "test": "vitest run",
58
- "test:coverage": "vitest run --coverage"
59
+ "vitest": "^4.1.9"
59
60
  }
60
- }
61
+ }
package/src/client.ts CHANGED
@@ -1,19 +1,37 @@
1
1
  /** HTTP client for a Derive server. Shared by the MCP server and any tooling. */
2
2
 
3
+ /** One exact-match search/replace edit (the Edit-tool contract). */
4
+ export interface DocEdit {
5
+ old_str: string
6
+ new_str: string
7
+ }
8
+
3
9
  export interface PublishArgs {
4
- content: string | Uint8Array
5
- filename: string
10
+ /** Full content for a fresh publish/republish. Omit when using `edits` instead. */
11
+ content?: string | Uint8Array
12
+ filename?: string
6
13
  title?: string
7
14
  slug?: string
8
15
  spa?: boolean
9
16
  message?: string
10
- visibility?: "public" | "link" | "org" | "password" | "private"
11
- /** Unlock password, required when visibility is "password". */
17
+ /** The v2 access triple for a NEW artifact (see access-model.md); ignored on a
18
+ * republish. */
19
+ workspaceAccess?: "none" | "member"
20
+ linkRole?: "none" | "viewer" | "commenter" | "editor"
21
+ listed?: "none" | "workspace" | "public"
22
+ /** A lock on the world link (optional). */
12
23
  password?: string
13
24
  /** When set, publishes a new version of this artifact instead of a new one. */
14
25
  id?: string
15
26
  /** Comment ids whose threads to resolve as part of this (re)publish. */
16
27
  resolves?: string[]
28
+ /** Open a review round for this version (the /derive loop's ask). */
29
+ requestReview?: boolean
30
+ /** Exact-match search/replace against the current stored source, INSTEAD of
31
+ * `content` — revises without resending the whole artifact. Requires `id`. */
32
+ edits?: DocEdit[]
33
+ /** Safety check for `edits`: reject if the artifact moved past this version. */
34
+ baseVersion?: number
17
35
  }
18
36
 
19
37
  export type CommentState = "open" | "addressed" | "resolved" | "outdated"
@@ -28,6 +46,8 @@ export interface CommentJson {
28
46
  author: string
29
47
  state: CommentState
30
48
  created_at: string
49
+ /** emoji → actor display names (the ack surface). */
50
+ reactions?: Record<string, string[]>
31
51
  }
32
52
 
33
53
  export interface ArtifactSummaryJson {
@@ -35,16 +55,23 @@ export interface ArtifactSummaryJson {
35
55
  title: string | null
36
56
  kind: "file" | "bundle"
37
57
  current_version: number
38
- visibility: string
58
+ workspace_access?: string
59
+ link_role?: string
60
+ listed?: string
39
61
  }
40
62
 
41
63
  /** A revision submitted for human review instead of published live. */
42
64
  export interface ProposeArgs {
43
- content: string
65
+ /** Full content for the proposal. Omit when using `edits` instead. */
66
+ content?: string
44
67
  filename?: string
45
68
  message: string
46
69
  /** Thread ids this revision addresses (flip to `addressed`, resolve on approval). */
47
70
  addresses?: string[]
71
+ /** Exact-match search/replace against the current stored source, INSTEAD of
72
+ * `content`. */
73
+ edits?: DocEdit[]
74
+ baseVersion?: number
48
75
  }
49
76
  export interface ProposalJson {
50
77
  id: string
@@ -81,11 +108,28 @@ export interface ArtifactJson {
81
108
  url: string
82
109
  title: string | null
83
110
  kind: "file" | "bundle"
84
- visibility: string
111
+ /** The v2 access triple (see access-model.md); optional so an older server that
112
+ * still returns `visibility` doesn't fail the type. */
113
+ workspace_access?: string
114
+ link_role?: string
115
+ listed?: string
85
116
  current_version: number
86
117
  versions: VersionJson[]
87
118
  /** Time-grouped version view (newest-first); present on the detail endpoint. */
88
119
  sessions?: SessionJson[]
120
+ /** Publish-response extras (agent-credentialed publishes only). */
121
+ review_requested?: boolean
122
+ opened_in_tab?: boolean
123
+ }
124
+
125
+ /** One review round: the human-ack primitive of the /derive loop. */
126
+ export interface ReviewRoundJson {
127
+ id: string
128
+ state: "pending" | "sent_back" | "approved"
129
+ version: number
130
+ note: string | null
131
+ created_at: string
132
+ resolved_at: string | null
89
133
  }
90
134
 
91
135
  export interface DiffOpJson {
@@ -106,6 +150,32 @@ export interface ViewStatsJson {
106
150
  recent: { viewer: string; kind: "user" | "anon"; at: string }[]
107
151
  }
108
152
 
153
+ export interface ContentOpts {
154
+ version?: number
155
+ /** A heading slug (single-file) or page path (bundle, optionally page#slug). */
156
+ section?: string
157
+ format?: "markdown" | "text"
158
+ }
159
+
160
+ /** A content read: the body plus the server's X-Derive-* capability headers, so a
161
+ * caller can tell an older self-hosted server (no headers at all) from a real
162
+ * raw-format response and degrade gracefully instead of misreading intent. */
163
+ export interface ContentResult {
164
+ text: string
165
+ /** Null when the server predates these params (no X-Derive-Format header). */
166
+ format: string | null
167
+ section: string | null
168
+ sectionCount: number | null
169
+ supportsParams: boolean
170
+ }
171
+
172
+ export interface OutlineSectionJson {
173
+ level: number
174
+ text: string
175
+ slug: string
176
+ chars: number
177
+ }
178
+
109
179
  export interface DeriveClient {
110
180
  /** List the workspace's artifacts (optionally filtered by a title query). */
111
181
  list(query?: string): Promise<ArtifactSummaryJson[]>
@@ -113,13 +183,26 @@ export interface DeriveClient {
113
183
  /** Submit a single-file revision for human review (does not go live). */
114
184
  propose(shortId: string, args: ProposeArgs): Promise<ProposalJson>
115
185
  get(shortId: string): Promise<ArtifactJson>
116
- getContent(shortId: string, version?: number): Promise<string>
186
+ getContent(shortId: string, opts?: ContentOpts): Promise<ContentResult>
187
+ /** The heading (single-file) or page (bundle) outline. Empty `sections` on an
188
+ * older server that doesn't understand `?outline=1` (it 400s or ignores it). */
189
+ getOutline(
190
+ shortId: string,
191
+ version?: number,
192
+ ): Promise<{ sections: OutlineSectionJson[]; pages: { path: string; type?: string }[] | null }>
117
193
  listComments(shortId: string, state?: CommentState): Promise<CommentJson[]>
118
194
  createComment(shortId: string, args: NewCommentArgs): Promise<CommentJson>
119
195
  /** Resolve or reopen the thread a comment belongs to. */
120
196
  setThreadState(shortId: string, commentId: string, state: "resolved" | "open"): Promise<void>
121
- /** Line diff between two versions (defaults: current-1 → current). */
122
- diff(shortId: string, from?: number, to?: number): Promise<DiffJson>
197
+ /** Line diff between two versions (defaults: current-1 → current). `content:
198
+ * "markdown"` diffs the readable Markdown form instead of raw source. */
199
+ diff(shortId: string, from?: number, to?: number, content?: "raw" | "markdown"): Promise<DiffJson>
200
+ /** The artifact's review rounds (newest first) + the pending one, if any. */
201
+ getReview(
202
+ shortId: string,
203
+ ): Promise<{ rounds: ReviewRoundJson[]; pending: ReviewRoundJson | null }>
204
+ /** Toggle an emoji reaction on a comment (the loop's lightweight ack). */
205
+ react(shortId: string, commentId: string, emoji: string): Promise<void>
123
206
  /** Restore a past version as a new current revision. */
124
207
  restore(shortId: string, version: number): Promise<ArtifactJson>
125
208
  /** Aggregated view analytics. */
@@ -129,6 +212,11 @@ export interface DeriveClient {
129
212
  export interface ClientOptions {
130
213
  baseUrl: string
131
214
  token?: string
215
+ /** Which workspace `token` acts in for this request — the token itself already
216
+ * reaches every workspace its owner belongs to; this just tells the server
217
+ * which one. Omit to fall back to the grant's own default (unchanged
218
+ * behavior for a plain static DERIVE_TOKEN). */
219
+ workspace?: string
132
220
  /** Override fetch (used in tests to target an in-process server). */
133
221
  fetchImpl?: typeof fetch
134
222
  }
@@ -136,9 +224,10 @@ export interface ClientOptions {
136
224
  export function createClient(opts: ClientOptions): DeriveClient {
137
225
  const base = opts.baseUrl.replace(/\/$/, "")
138
226
  const f = opts.fetchImpl ?? fetch
139
- const authHeaders: Record<string, string> = opts.token
140
- ? { Authorization: `Bearer ${opts.token}` }
141
- : {}
227
+ const authHeaders: Record<string, string> = {
228
+ ...(opts.token ? { Authorization: `Bearer ${opts.token}` } : {}),
229
+ ...(opts.workspace ? { "X-Derive-Workspace": opts.workspace } : {}),
230
+ }
142
231
 
143
232
  async function ok(res: Response): Promise<unknown> {
144
233
  if (res.ok) return res.json()
@@ -156,17 +245,28 @@ export function createClient(opts: ClientOptions): DeriveClient {
156
245
  },
157
246
 
158
247
  async publish(args) {
159
- const bytes =
160
- typeof args.content === "string" ? new TextEncoder().encode(args.content) : args.content
161
248
  const form = new FormData()
162
- form.append("file", new Blob([bytes as BlobPart]), args.filename)
249
+ if (args.edits) {
250
+ // Surgical revision: no file upload, the server materializes it from the
251
+ // current stored source. Requires an existing artifact (args.id).
252
+ form.append("edits", JSON.stringify(args.edits))
253
+ if (args.baseVersion != null) form.append("base_version", String(args.baseVersion))
254
+ if (args.filename) form.append("filename", args.filename)
255
+ } else {
256
+ const bytes =
257
+ typeof args.content === "string" ? new TextEncoder().encode(args.content) : args.content
258
+ form.append("file", new Blob([bytes as BlobPart]), args.filename ?? "index.html")
259
+ }
163
260
  if (args.title) form.append("title", args.title)
164
261
  if (args.slug) form.append("slug", args.slug)
165
262
  if (args.message) form.append("message", args.message)
166
- if (args.visibility) form.append("visibility", args.visibility)
263
+ if (args.workspaceAccess) form.append("workspace_access", args.workspaceAccess)
264
+ if (args.linkRole) form.append("link_role", args.linkRole)
265
+ if (args.listed) form.append("listed", args.listed)
167
266
  if (args.password) form.append("password", args.password)
168
267
  if (args.spa) form.append("spa", "true")
169
268
  if (args.resolves?.length) form.append("resolves", args.resolves.join(","))
269
+ if (args.requestReview) form.append("request_review", "true")
170
270
  const url = args.id ? `${base}/v1/artifacts/${args.id}/versions` : `${base}/v1/artifacts`
171
271
  return ok(
172
272
  await f(url, { method: "POST", body: form, headers: authHeaders }),
@@ -175,11 +275,16 @@ export function createClient(opts: ClientOptions): DeriveClient {
175
275
 
176
276
  async propose(shortId, args) {
177
277
  const form = new FormData()
178
- form.append(
179
- "file",
180
- new Blob([new TextEncoder().encode(args.content)]),
181
- args.filename ?? "index.html",
182
- )
278
+ if (args.edits) {
279
+ form.append("edits", JSON.stringify(args.edits))
280
+ if (args.baseVersion != null) form.append("base_version", String(args.baseVersion))
281
+ } else {
282
+ form.append(
283
+ "file",
284
+ new Blob([new TextEncoder().encode(args.content ?? "")]),
285
+ args.filename ?? "index.html",
286
+ )
287
+ }
183
288
  form.append("message", args.message)
184
289
  if (args.addresses?.length) form.append("addresses", args.addresses.join(","))
185
290
  return ok(
@@ -197,14 +302,44 @@ export function createClient(opts: ClientOptions): DeriveClient {
197
302
  ) as Promise<ArtifactJson>
198
303
  },
199
304
 
200
- async getContent(shortId, version) {
201
- const q = version ? `?v=${version}` : ""
202
- const res = await f(`${base}/v1/artifacts/${shortId}/content${q}`, { headers: authHeaders })
305
+ async getContent(shortId, opts) {
306
+ const q = new URLSearchParams()
307
+ if (opts?.version) q.set("v", String(opts.version))
308
+ if (opts?.section) q.set("section", opts.section)
309
+ if (opts?.format) q.set("format", opts.format)
310
+ const qs = q.toString()
311
+ const res = await f(`${base}/v1/artifacts/${shortId}/content${qs ? `?${qs}` : ""}`, {
312
+ headers: authHeaders,
313
+ })
203
314
  if (!res.ok) {
204
315
  const body = (await res.json().catch(() => ({}))) as { error?: string }
205
316
  throw new Error(`derive ${res.status}: ${body.error ?? res.statusText}`)
206
317
  }
207
- return res.text()
318
+ const format = res.headers.get("x-derive-format")
319
+ return {
320
+ text: await res.text(),
321
+ format,
322
+ section: res.headers.get("x-derive-section"),
323
+ sectionCount: res.headers.has("x-derive-sections")
324
+ ? Number(res.headers.get("x-derive-sections"))
325
+ : null,
326
+ // No X-Derive-Format header at all = a server that predates these params
327
+ // (an older self-hosted instance) — the caller should treat this as raw
328
+ // whole-artifact content and not assume format/section were honored.
329
+ supportsParams: format !== null,
330
+ }
331
+ },
332
+
333
+ async getOutline(shortId, version) {
334
+ const q = new URLSearchParams({ outline: "1" })
335
+ if (version) q.set("v", String(version))
336
+ const res = await f(`${base}/v1/artifacts/${shortId}/content?${q}`, { headers: authHeaders })
337
+ if (!res.ok) return { sections: [], pages: null }
338
+ const body = (await res.json()) as {
339
+ sections?: OutlineSectionJson[]
340
+ pages?: { path: string; type?: string }[]
341
+ }
342
+ return { sections: body.sections ?? [], pages: body.pages ?? null }
208
343
  },
209
344
 
210
345
  async listComments(shortId, state) {
@@ -235,15 +370,32 @@ export function createClient(opts: ClientOptions): DeriveClient {
235
370
  )
236
371
  },
237
372
 
238
- async diff(shortId, from, to) {
373
+ async diff(shortId, from, to, content) {
239
374
  const q = new URLSearchParams({ format: "json" })
240
375
  if (from != null) q.set("from", String(from))
241
376
  if (to != null) q.set("to", String(to))
377
+ if (content === "markdown") q.set("content", "markdown")
242
378
  return ok(
243
379
  await f(`${base}/v1/artifacts/${shortId}/diff?${q}`, { headers: authHeaders }),
244
380
  ) as Promise<DiffJson>
245
381
  },
246
382
 
383
+ async getReview(shortId) {
384
+ return ok(
385
+ await f(`${base}/v1/artifacts/${shortId}/review`, { headers: authHeaders }),
386
+ ) as Promise<{ rounds: ReviewRoundJson[]; pending: ReviewRoundJson | null }>
387
+ },
388
+
389
+ async react(shortId, commentId, emoji) {
390
+ await ok(
391
+ await f(`${base}/v1/artifacts/${shortId}/comments/${commentId}/react`, {
392
+ method: "POST",
393
+ headers: { ...authHeaders, "content-type": "application/json" },
394
+ body: JSON.stringify({ emoji }),
395
+ }),
396
+ )
397
+ },
398
+
247
399
  async restore(shortId, version) {
248
400
  return ok(
249
401
  await f(`${base}/v1/artifacts/${shortId}/restore`, {
package/src/index.ts CHANGED
@@ -1,21 +1,96 @@
1
1
  import { readFileSync } from "node:fs"
2
2
  import { fileURLToPath } from "node:url"
3
+ import {
4
+ findAccountWorkspace,
5
+ freshToken,
6
+ getAccount,
7
+ getDefault,
8
+ listAccounts,
9
+ resolveAccountRef,
10
+ resolveWorkspaceRef,
11
+ } from "@derive-to/cli/config"
3
12
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"
4
13
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
5
14
  import { z } from "zod"
6
15
  import { createClient } from "./client"
7
16
 
8
17
  // Stdio MCP server for self-hosters: `npx @derive-to/mcp` talks to a Derive instance over
9
- // the /v1 HTTP API (DERIVE_SERVER) with a bearer (DERIVE_TOKEN). It exposes the SAME five
10
- // tools as the remote /mcp server — list_artifacts, read, catch_up, comment, publish —
11
- // so the vocabulary is identical whether an agent connects over OAuth or a static
12
- // token. (A static token already has publish rights, so publish here goes live unless
13
- // you pass for_review; bundle publishing is remote-only.)
18
+ // the /v1 HTTP API (DERIVE_SERVER). It exposes the SAME tools as the remote /mcp
19
+ // server — list_workspaces, list_artifacts, read, catch_up, comment, publish — so the
20
+ // vocabulary is identical whether an agent connects over OAuth or a static token.
21
+ //
22
+ // No token to paste: by default this reads the SAME local store `derive login`
23
+ // writes (~/.config/derive/credentials.json), refreshing silently — sign in once
24
+ // on the machine and every project's MCP server just works. DERIVE_ACCOUNT /
25
+ // DERIVE_WORKSPACE pin which signed-in account/workspace THIS project acts as by
26
+ // DEFAULT (id or name); unset, it falls back to your stored default. Because one
27
+ // login reaches every workspace the account belongs to, any tool also takes a
28
+ // per-call `workspace` argument (see list_workspaces) to act in another one without
29
+ // changing that pin. DERIVE_TOKEN remains an escape hatch for a static bearer (CI,
30
+ // no local login) — DERIVE_WORKSPACE has no effect there, since a static token
31
+ // already acts as every workspace's owner.
32
+ const server_ = process.env.DERIVE_SERVER ?? "http://localhost:8080"
14
33
 
15
- const client = createClient({
16
- baseUrl: process.env.DERIVE_SERVER ?? "http://localhost:8080",
17
- token: process.env.DERIVE_TOKEN,
18
- })
34
+ /** {token, workspace} for the client below — see the module doc comment for the
35
+ * precedence. Kept out of `createClient` itself so a resolution failure fails
36
+ * loudly at startup (a clear thrown message) instead of quietly targeting the
37
+ * wrong workspace, or surfacing as an unexplained 401 mid-session. Not signed
38
+ * in at all (no env override, nothing saved) degrades gracefully to anonymous,
39
+ * same as today's unset DERIVE_TOKEN — only an env var naming something that
40
+ * doesn't exist is an error. */
41
+ async function resolveAuth(): Promise<{
42
+ token?: string
43
+ workspace?: string
44
+ accountId?: string
45
+ }> {
46
+ if (process.env.DERIVE_TOKEN) return { token: process.env.DERIVE_TOKEN }
47
+
48
+ const accountEnv = process.env.DERIVE_ACCOUNT
49
+ const workspaceEnv = process.env.DERIVE_WORKSPACE
50
+ let accountId: string | null
51
+ let workspace: string | undefined
52
+
53
+ if (accountEnv) {
54
+ accountId = resolveAccountRef(server_, accountEnv)
55
+ if (!accountId)
56
+ throw new Error(
57
+ `DERIVE_ACCOUNT "${accountEnv}" isn't signed in on this machine — run \`derive login\`.`,
58
+ )
59
+ if (workspaceEnv) {
60
+ const found = findAccountWorkspace(server_, accountId, workspaceEnv)
61
+ if (!found)
62
+ throw new Error(
63
+ `DERIVE_WORKSPACE "${workspaceEnv}" isn't one of that account's workspaces — run \`derive workspaces --account ${accountEnv}\`.`,
64
+ )
65
+ workspace = found.id
66
+ } else {
67
+ workspace = getAccount(server_, accountId)?.defaultWorkspace ?? undefined
68
+ }
69
+ } else if (workspaceEnv) {
70
+ const resolved = resolveWorkspaceRef(server_, workspaceEnv)
71
+ if (!resolved)
72
+ throw new Error(
73
+ `DERIVE_WORKSPACE "${workspaceEnv}" isn't a workspace on any signed-in account.`,
74
+ )
75
+ if ("ambiguous" in resolved)
76
+ throw new Error(
77
+ `DERIVE_WORKSPACE "${workspaceEnv}" matches workspaces under more than one account — set DERIVE_ACCOUNT too.`,
78
+ )
79
+ accountId = resolved.accountId
80
+ workspace = resolved.workspaceId
81
+ } else {
82
+ const def = getDefault(server_)
83
+ accountId = def?.account ?? null
84
+ workspace = def?.workspace ?? undefined
85
+ }
86
+
87
+ if (!accountId) return {}
88
+ const token = (await freshToken(server_, accountId)) ?? undefined
89
+ return { token, workspace, accountId }
90
+ }
91
+
92
+ const { token, workspace, accountId } = await resolveAuth()
93
+ const client = createClient({ baseUrl: server_, token, workspace })
19
94
 
20
95
  // The agent guide, served as an MCP resource (single source: SKILL.md).
21
96
  const GUIDE = (() => {
@@ -30,17 +105,94 @@ const server = new McpServer({ name: "derive", version: "1.0.0" })
30
105
 
31
106
  const text = (s: string) => ({ content: [{ type: "text" as const, text: s }] })
32
107
  const json = (v: unknown) => text(JSON.stringify(v, null, 2))
108
+ const err = (s: string) => ({
109
+ content: [{ type: "text" as const, text: s }],
110
+ isError: true as const,
111
+ })
112
+
113
+ // A content-bearing response: a frontmatter-style header, a blank line, then the
114
+ // RAW body — never JSON-escaped (parity with the remote server's envelope).
115
+ const doc = (meta: Record<string, string | number | null | undefined>, body: string) => {
116
+ const head = Object.entries(meta)
117
+ .filter(([, v]) => v !== undefined && v !== null)
118
+ .map(([k, v]) => `${k}: ${v}`)
119
+ .join("\n")
120
+ return text(`---\n${head}\n---\n\n${body}`)
121
+ }
122
+
123
+ // A `workspace` arg (id or name) on any tool acts in THAT workspace for the one
124
+ // call, without re-pinning the session: the token already reaches every workspace
125
+ // the account belongs to, so we just build a throwaway client that sends the
126
+ // matching X-Derive-Workspace header. Names resolve against the active account's
127
+ // local roster; an unrecognized ref is passed through as a literal id (a static
128
+ // DERIVE_TOKEN caller has no roster to match names against). Omit → the session's
129
+ // resolved default client.
130
+ const resolveWsId = (ref: string): string => {
131
+ if (accountId) {
132
+ const found = findAccountWorkspace(server_, accountId, ref)
133
+ if (found) return found.id
134
+ }
135
+ return ref
136
+ }
137
+ const clientFor = (ref?: string) =>
138
+ ref ? createClient({ baseUrl: server_, token, workspace: resolveWsId(ref) }) : client
139
+ const wsArg = z
140
+ .string()
141
+ .optional()
142
+ .describe(
143
+ "Workspace to act in — its id or name from list_workspaces. Omit to use this session's default workspace.",
144
+ )
145
+
146
+ // The signed-in roster on THIS machine (shared by the list_workspaces tool and the
147
+ // derive://workspaces resource) — read fresh so a sibling `derive login`/`describe`
148
+ // shows up mid-session.
149
+ const buildRoster = () => {
150
+ const accounts = listAccounts(server_).map((a) => {
151
+ const account = getAccount(server_, a.id)
152
+ return {
153
+ account_id: a.id,
154
+ handle: a.handle,
155
+ is_default_account: a.isDefault,
156
+ workspaces: Object.entries(account?.workspaces ?? {}).map(([id, w]) => ({
157
+ workspace_id: id,
158
+ name: w.name,
159
+ role: w.role,
160
+ description: w.description ?? null,
161
+ is_default_workspace: id === account?.defaultWorkspace,
162
+ is_active: id === workspace,
163
+ })),
164
+ }
165
+ })
166
+ const active = accountId
167
+ ? { server: server_, account_id: accountId, workspace_id: workspace ?? null }
168
+ : { server: server_, note: "No signed-in account resolved for this session." }
169
+ return { active, accounts }
170
+ }
171
+
172
+ // WORKSPACES — the switcher: every workspace signed in on this machine ---------
173
+ server.registerTool(
174
+ "list_workspaces",
175
+ {
176
+ description:
177
+ "List every workspace signed in on this machine you can act in — id, name, your role, local description, and which is active. One login reaches them all; pass a workspace's id or name as the `workspace` argument to list_artifacts / read / catch_up / comment / publish to act there for that call.",
178
+ inputSchema: {},
179
+ },
180
+ async () => json(buildRoster()),
181
+ )
33
182
 
34
183
  // FIND ------------------------------------------------------------------------
35
184
  server.registerTool(
36
185
  "list_artifacts",
37
186
  {
38
187
  description:
39
- "List the artifacts in your workspace — short id, title, kind, current version, visibility. Start here to find what to work on, then catch_up or read it.",
40
- inputSchema: { query: z.string().optional().describe("Optional title search filter.") },
188
+ "List the artifacts in your workspace — short id, title, kind, current version, access. Defaults to this session's workspace; pass `workspace` (id or name from list_workspaces) to list another. Start here to find what to work on, then catch_up or read it.",
189
+ inputSchema: {
190
+ query: z.string().optional().describe("Optional title search filter."),
191
+ workspace: wsArg,
192
+ },
41
193
  },
42
- async ({ query }) => {
43
- const arts = await client.list(query)
194
+ async ({ query, workspace: ws }) => {
195
+ const arts = await clientFor(ws).list(query)
44
196
  return json({ count: arts.length, artifacts: arts })
45
197
  },
46
198
  )
@@ -50,17 +202,74 @@ server.registerTool(
50
202
  "read",
51
203
  {
52
204
  description:
53
- "Read an artifact's CONTENT by short id (a past `version` defaults to current). For what CHANGED or the comment threads, use catch_up instead.",
205
+ "Read an artifact's CONTENT by short id, as Markdown by default (HTML is converted). Omit `section` to see the outline first (heading slugs for a single-file doc, page paths for a bundle) — call again with a `section` (or \"*\" for the full document) once you know what you want. Pass `format:'html'` for the exact source (needed before publish `edits`), or a past `version` for history. For what CHANGED or the comment threads, use catch_up instead. (Older self-hosted servers that predate these params return the whole artifact regardless of section/format — noted in the response when that happens.)",
54
206
  inputSchema: {
55
207
  short_id: z.string(),
208
+ section: z
209
+ .string()
210
+ .optional()
211
+ .describe(
212
+ 'A heading slug (single-file) or page path (bundle, optionally page#slug). Pass "*" for the full document.',
213
+ ),
214
+ format: z
215
+ .enum(["markdown", "text"])
216
+ .optional()
217
+ .describe("markdown (default, HTML converted) or text (flat visible text)."),
56
218
  version: z.number().int().optional().describe("Defaults to the current version."),
219
+ workspace: wsArg,
57
220
  },
58
221
  },
59
- async ({ short_id, version }) => {
222
+ async ({ short_id, section, format, version, workspace: ws }) => {
223
+ const client = clientFor(ws)
60
224
  const a = await client.get(short_id)
61
- const body = await client.getContent(short_id, version)
62
225
  const v = version ?? a.current_version
63
- return json({ short_id, title: a.title, kind: a.kind, version: v, content: body })
226
+
227
+ // No section: show the outline first (mirrors the remote server's
228
+ // outline-before-blind-dump behavior). Falls back to full content when the
229
+ // artifact has no headings/pages, or the server predates `?outline=1`.
230
+ if (!section) {
231
+ const outline = await client.getOutline(short_id, version)
232
+ if (outline.sections.length || outline.pages) {
233
+ return json({
234
+ short_id,
235
+ title: a.title,
236
+ kind: a.kind,
237
+ version: v,
238
+ ...(outline.sections.length ? { sections: outline.sections } : {}),
239
+ ...(outline.pages ? { pages: outline.pages } : {}),
240
+ next:
241
+ outline.sections.length || outline.pages?.length
242
+ ? 'Call read again with a `section` (a slug/page above), or section:"*" for the full document.'
243
+ : undefined,
244
+ })
245
+ }
246
+ }
247
+
248
+ try {
249
+ const result = await client.getContent(short_id, {
250
+ version,
251
+ section,
252
+ format: format ?? "markdown",
253
+ })
254
+ if (!result.supportsParams)
255
+ return doc(
256
+ { short_id, title: a.title, kind: a.kind, version: v },
257
+ `${result.text}\n\n[note: this server predates section/format params — returning the full raw artifact.]`,
258
+ )
259
+ return doc(
260
+ {
261
+ short_id,
262
+ title: a.title,
263
+ kind: a.kind,
264
+ version: v,
265
+ ...(result.format ? { format: result.format } : {}),
266
+ ...(result.section ? { section: result.section } : {}),
267
+ },
268
+ result.text,
269
+ )
270
+ } catch (e) {
271
+ return err(e instanceof Error ? e.message : "read failed")
272
+ }
64
273
  },
65
274
  )
66
275
 
@@ -69,9 +278,10 @@ server.registerTool(
69
278
  "catch_up",
70
279
  {
71
280
  description:
72
- "START HERE on an artifact. Its state in one call: a summary, the versions since `since_version`, the open (and outdated) comment threads, and the full version history. " +
281
+ "START HERE on an artifact. Its state in one call: a summary, the review round, the versions since `since_version`, the open (and outdated) comment threads, and the full version history. " +
73
282
  "Pass `comments` (open / addressed / resolved / outdated) to instead get that filtered thread list — your feedback queue. " +
74
- "Pass `response_format='detailed'` (optionally with `since_version`/`to_version`) to fold in the exact line diff between two versions.",
283
+ "Pass `response_format='detailed'` (optionally with `since_version`/`to_version`) to fold in a line diff between two versions — of their readable Markdown form, not raw HTML. " +
284
+ "WAITING ON A REVIEW? Pass `wait` (seconds, max 50) to block until the human sends back or approves — chain these instead of sleeping between polls.",
75
285
  inputSchema: {
76
286
  short_id: z.string(),
77
287
  since_version: z
@@ -94,9 +304,28 @@ server.registerTool(
94
304
  .enum(["summary", "detailed"])
95
305
  .optional()
96
306
  .describe("'summary' (default) omits the line diff; 'detailed' includes it."),
307
+ wait: z
308
+ .number()
309
+ .int()
310
+ .min(1)
311
+ .max(50)
312
+ .optional()
313
+ .describe(
314
+ "Long-poll: block up to this many seconds for the human's next review action before returning. Returns immediately when something is already actionable.",
315
+ ),
316
+ workspace: wsArg,
97
317
  },
98
318
  },
99
- async ({ short_id, since_version, to_version, comments, response_format }) => {
319
+ async ({
320
+ short_id,
321
+ since_version,
322
+ to_version,
323
+ comments,
324
+ response_format,
325
+ wait,
326
+ workspace: ws,
327
+ }) => {
328
+ const client = clientFor(ws)
100
329
  const summarizeComment = (c: {
101
330
  thread_id: string
102
331
  author: string
@@ -121,20 +350,76 @@ server.registerTool(
121
350
  })
122
351
  }
123
352
 
353
+ // Long-poll (self-host shim flavor): the /v1 API has no blocking endpoint,
354
+ // so poll every 2.5s until the human acts or the wait runs out — the same
355
+ // contract as the remote server's wait on a coarser clock. "Acts" = the
356
+ // round changes OR the open-comment count moves (so waiting works with no
357
+ // round open, exactly like the server's comment.created wake). Transient
358
+ // errors retry; they never end the wait early. A settled round that still
359
+ // applies to the current head is already actionable and returns at once.
360
+ if (wait) {
361
+ const deadline = Date.now() + wait * 1000
362
+ const snap = () =>
363
+ Promise.all([
364
+ client.get(short_id),
365
+ client.getReview(short_id),
366
+ client.listComments(short_id, "open"),
367
+ ]).then(([art, rev, open]) => {
368
+ const round = rev.pending ?? rev.rounds[0] ?? null
369
+ return {
370
+ key: `${round?.id ?? "none"}:${round?.state ?? "none"}:${open.length}`,
371
+ actionable:
372
+ !!round && round.state !== "pending" && round.version >= art.current_version,
373
+ }
374
+ })
375
+ let baseline: string | null = null
376
+ for (;;) {
377
+ const cur = await snap().catch(() => null)
378
+ if (cur) {
379
+ if (baseline === null) {
380
+ baseline = cur.key
381
+ if (cur.actionable) break
382
+ } else if (cur.key !== baseline) break
383
+ }
384
+ if (Date.now() >= deadline) break
385
+ await new Promise((r) => setTimeout(r, 2500))
386
+ }
387
+ }
388
+
124
389
  const a = await client.get(short_id)
125
390
  const head = a.current_version
126
391
  const to = Math.min(head, Math.max(1, to_version ?? head))
127
392
  const since = Math.min(to, Math.max(1, since_version ?? to - 1))
128
393
  const history = a.versions.slice().sort((x, y) => y.n - x.n)
129
394
  const newVersions = history.filter((v) => v.n > since && v.n <= to)
130
- const [open, outdated, addressed] = await Promise.all([
395
+ const [open, outdated, addressed, reviewState] = await Promise.all([
131
396
  client.listComments(short_id, "open"),
132
397
  client.listComments(short_id, "outdated"),
133
398
  client.listComments(short_id, "addressed"),
399
+ client.getReview(short_id).catch(() => ({ rounds: [], pending: null })),
134
400
  ])
401
+ const round = reviewState.pending ?? reviewState.rounds[0] ?? null
402
+ const review = round
403
+ ? {
404
+ state: round.state,
405
+ version: round.version,
406
+ requested_at: round.created_at,
407
+ resolved_at: round.resolved_at,
408
+ note: round.note,
409
+ }
410
+ : null
411
+ const reviewBit = review
412
+ ? review.state === "pending"
413
+ ? ` Review requested on v${review.version} — waiting for the human.`
414
+ : review.state === "sent_back"
415
+ ? ` The human sent back their review of v${review.version} — read the open threads, revise, and re-request.`
416
+ : ` The human approved v${review.version} — you're clear to proceed.`
417
+ : ""
135
418
  let entryDiff: string | undefined
136
419
  if (response_format === "detailed" && since < to) {
137
- const d = await client.diff(short_id, since, to)
420
+ // Diff the readable Markdown form, not raw HTML — kills tag noise and
421
+ // avoids a minified one-line document producing one useless del/add pair.
422
+ const d = await client.diff(short_id, since, to, "markdown")
138
423
  entryDiff = d.ops
139
424
  .map((o) => `${o.t === "add" ? "+" : o.t === "del" ? "-" : " "} ${o.line}`)
140
425
  .join("\n")
@@ -143,10 +428,11 @@ server.registerTool(
143
428
  const addressedBit = addressed.length ? ` ${addressed.length} addressed (pending review).` : ""
144
429
  const summary =
145
430
  since >= to
146
- ? `You're up to date on "${a.title}" (v${head}); ${open.length} open comment(s).${addressedBit}${outdatedBit}`
147
- : `"${a.title}": ${newVersions.length} new version(s) since v${since} (now v${to}). ${open.length} open comment(s).${addressedBit}${outdatedBit}`
431
+ ? `You're up to date on "${a.title}" (v${head}); ${open.length} open comment(s).${addressedBit}${outdatedBit}${reviewBit}`
432
+ : `"${a.title}": ${newVersions.length} new version(s) since v${since} (now v${to}). ${open.length} open comment(s).${addressedBit}${outdatedBit}${reviewBit}`
148
433
  return json({
149
434
  summary,
435
+ review,
150
436
  short_id,
151
437
  since,
152
438
  to,
@@ -170,7 +456,7 @@ server.registerTool(
170
456
  "comment",
171
457
  {
172
458
  description:
173
- "Leave feedback, reply in a thread, and/or resolve or reopen a thread. Anchor a NEW comment to a quoted span with `quote`. Reply by passing the thread id as `reply_to`. Resolve/reopen by passing `set_state` with a `comment_id` from the thread (or the comment you just left).",
459
+ "Leave feedback, reply in a thread, react, and/or resolve or reopen a thread. Anchor a NEW comment to a quoted span with `quote`. Reply by passing the thread id as `reply_to`. Pass `react` with a `comment_id` (or `reply_to` to hit the thread's latest comment) to acknowledge feedback without the noise of a reply — the loop's minimum ack. Resolve/reopen by passing `set_state` with a `comment_id` from the thread (or the comment you just left).",
174
460
  inputSchema: {
175
461
  short_id: z.string(),
176
462
  body: z
@@ -182,16 +468,24 @@ server.registerTool(
182
468
  .optional()
183
469
  .describe("A thread id to reply in; omit to start a new thread."),
184
470
  quote: z.string().optional().describe("Exact text to anchor a NEW comment to."),
471
+ react: z
472
+ .enum(["👍", "❤️", "🎉", "😄", "👀", "🙏", "🚀", "👎"])
473
+ .optional()
474
+ .describe("React to a comment (with `comment_id` or `reply_to`) — 👍 is the loop's ack."),
185
475
  set_state: z.enum(["resolved", "open"]).optional().describe("Resolve or reopen a thread."),
186
476
  comment_id: z
187
477
  .string()
188
478
  .optional()
189
- .describe("A comment in the thread to set_state on (when not posting)."),
479
+ .describe("A comment in the thread to react to / set_state on (when not posting)."),
480
+ workspace: wsArg,
190
481
  },
191
482
  },
192
- async ({ short_id, body, reply_to, quote, set_state, comment_id }) => {
193
- if (!body && !set_state)
194
- return text("Provide `body` (to comment) or `set_state` (to resolve/reopen).")
483
+ async ({ short_id, body, reply_to, quote, react, set_state, comment_id, workspace: ws }) => {
484
+ const client = clientFor(ws)
485
+ if (!body && !set_state && !react)
486
+ return text(
487
+ "Provide `body` (to comment), `react` (to acknowledge), or `set_state` (to resolve/reopen).",
488
+ )
195
489
  let posted: Awaited<ReturnType<typeof client.createComment>> | undefined
196
490
  if (body) {
197
491
  const anchor = quote ? { type: "TextQuoteSelector", exact: quote } : undefined
@@ -202,6 +496,32 @@ server.registerTool(
202
496
  author: "agent",
203
497
  })
204
498
  }
499
+ let reactNote = ""
500
+ if (react) {
501
+ // The ack target: an explicit comment, else the newest comment in the
502
+ // thread by someone ELSE — never the agent's own just-posted reply. One
503
+ // unfiltered fetch covers every thread state (the human may have replied
504
+ // on a resolved thread).
505
+ const all = await client.listComments(short_id)
506
+ let target = comment_id
507
+ if (!target && reply_to) {
508
+ const thread = all
509
+ .filter((cm) => cm.thread_id === reply_to)
510
+ .sort((x, y) => x.created_at.localeCompare(y.created_at))
511
+ const other = [...thread].reverse().find((cm) => cm.author !== "agent")
512
+ target = (other ?? thread[thread.length - 1])?.id
513
+ }
514
+ if (!target)
515
+ return text("`react` needs a `comment_id` or a `reply_to` thread to acknowledge.")
516
+ // The /react route TOGGLES; skipping an already-present emoji keeps a
517
+ // retried ack from silently removing it.
518
+ if (all.find((cm) => cm.id === target)?.reactions?.[react]?.length) {
519
+ reactNote = ` · already acknowledged with ${react}`
520
+ } else {
521
+ await client.react(short_id, target, react)
522
+ reactNote = ` · acknowledged with ${react}`
523
+ }
524
+ }
205
525
  let stateNote = ""
206
526
  if (set_state) {
207
527
  const ref = posted?.id ?? comment_id
@@ -216,9 +536,12 @@ server.registerTool(
216
536
  const where = reply_to
217
537
  ? `replied in thread ${posted.thread_id}`
218
538
  : `new thread ${posted.thread_id}`
219
- return text(`${where} (comment ${posted.id})${quote ? ` on “${quote}”` : ""}${stateNote}.`)
539
+ return text(
540
+ `${where} (comment ${posted.id})${quote ? ` on “${quote}”` : ""}${reactNote}${stateNote}.`,
541
+ )
220
542
  }
221
- return text(`Thread ${set_state === "resolved" ? "resolved" : "reopened"}.`)
543
+ if (!set_state) return text(`Acknowledged${reactNote.replace(" · acknowledged", "")}.`)
544
+ return text(`Thread ${set_state === "resolved" ? "resolved" : "reopened"}${reactNote}.`)
222
545
  },
223
546
  )
224
547
 
@@ -227,9 +550,33 @@ server.registerTool(
227
550
  "publish",
228
551
  {
229
552
  description:
230
- "Publish a single-file artifact and get a permanent URL. OMIT short_id to create a NEW artifact (title recommended); PASS short_id to publish a new version (same URL). Pass for_review:true to file it as a PROPOSAL a human approves instead of going live. Pass `addresses` with the thread ids this revision resolves. (Multi-page bundles are published via the web app or the remote /mcp server.)",
553
+ "Publish a single-file artifact and get a permanent URL. OMIT short_id to create a NEW artifact (title recommended); PASS short_id to publish a new version (same URL). To CHANGE PART of an existing artifact, prefer `edits` (exact-match search/replace against the stored source — read format:'html' first) over resending everything via `content`. Pass for_review:true to file it as a PROPOSAL a human approves instead of going live. Pass `addresses` with the thread ids this revision resolves. (Multi-page bundles are published via the web app or the remote /mcp server.)",
231
554
  inputSchema: {
232
- content: z.string().describe("The artifact's text content (HTML or Markdown)."),
555
+ content: z
556
+ .string()
557
+ .optional()
558
+ .describe("The artifact's full text content (HTML or Markdown). Use this OR `edits`."),
559
+ edits: z
560
+ .array(
561
+ z.object({
562
+ old_str: z
563
+ .string()
564
+ .describe(
565
+ "Exact text from the STORED SOURCE (read format:'html' first on an HTML artifact). Must occur exactly once.",
566
+ ),
567
+ new_str: z.string().describe("Replacement text. Empty string deletes."),
568
+ }),
569
+ )
570
+ .optional()
571
+ .describe(
572
+ "Surgical revision without resending the artifact: exact-match search/replace against the current stored source, applied in order. Errors (applying nothing) if any old_str matches zero or multiple times. Requires `short_id`; use INSTEAD of `content`.",
573
+ ),
574
+ base_version: z
575
+ .number()
576
+ .optional()
577
+ .describe(
578
+ "Safety check for `edits`: pass the version you read; errors instead of applying if the artifact moved past it.",
579
+ ),
233
580
  filename: z
234
581
  .string()
235
582
  .optional()
@@ -239,10 +586,12 @@ server.registerTool(
239
586
  .optional()
240
587
  .describe("Omit to create a new artifact; pass it to add a version."),
241
588
  title: z.string().optional(),
242
- // `password` stays CLI/web-only (it needs a password argument this tool
243
- // doesn't take). Omitted the server default, `private` (the publish is
244
- // owned by the user the agent acts on behalf of).
245
- visibility: z.enum(["public", "link", "org", "private"]).optional(),
589
+ // The v2 access triple for a NEW artifact (see access-model.md); omit any to
590
+ // take the workspace default (the team draft the human you act for owns it
591
+ // and promotes it when ready). Ignored on a republish.
592
+ workspace_access: z.enum(["none", "member"]).optional(),
593
+ link_role: z.enum(["none", "viewer", "commenter", "editor"]).optional(),
594
+ listed: z.enum(["none", "workspace", "public"]).optional(),
246
595
  message: z.string().optional().describe("What changed in this version."),
247
596
  for_review: z
248
597
  .boolean()
@@ -252,42 +601,93 @@ server.registerTool(
252
601
  .array(z.string())
253
602
  .optional()
254
603
  .describe("Thread ids this revision resolves (live publish) or addresses (proposal)."),
604
+ request_review: z
605
+ .boolean()
606
+ .optional()
607
+ .describe(
608
+ "Open a review round asking your human to review this version — the /derive loop. Poll catch_up's `review` (or pass `wait`) for the state.",
609
+ ),
610
+ workspace: wsArg,
255
611
  },
256
612
  },
257
- async ({ content, filename, short_id, title, visibility, message, for_review, addresses }) => {
613
+ async ({
614
+ content,
615
+ edits,
616
+ base_version,
617
+ filename,
618
+ short_id,
619
+ title,
620
+ workspace_access,
621
+ link_role,
622
+ listed,
623
+ message,
624
+ for_review,
625
+ addresses,
626
+ request_review,
627
+ workspace: ws,
628
+ }) => {
629
+ const client = clientFor(ws)
630
+ if (content !== undefined && edits) return text("Provide `content` OR `edits`, not both.")
258
631
  if (for_review) {
259
632
  if (!short_id) return text("A proposal revises an EXISTING artifact — pass its short_id.")
260
- const p = await client.propose(short_id, {
633
+ try {
634
+ const p = await client.propose(short_id, {
635
+ content,
636
+ edits,
637
+ baseVersion: base_version,
638
+ filename,
639
+ message: message ?? "Proposed revision",
640
+ addresses,
641
+ })
642
+ const note = p.addressed?.length ? ` · addressed ${p.addressed.length} thread(s)` : ""
643
+ return json({
644
+ proposed: true,
645
+ proposal_id: p.id,
646
+ base_version: p.base_version,
647
+ note: `Submitted for review (not live)${note}.`,
648
+ })
649
+ } catch (e) {
650
+ return err(e instanceof Error ? e.message : "propose failed")
651
+ }
652
+ }
653
+ if (edits && !short_id) return text("`edits` revises an EXISTING artifact — pass its short_id.")
654
+ let a: Awaited<ReturnType<typeof client.publish>>
655
+ try {
656
+ a = await client.publish({
657
+ id: short_id,
261
658
  content,
262
- filename,
263
- message: message ?? "Proposed revision",
264
- addresses,
265
- })
266
- const note = p.addressed?.length ? ` · addressed ${p.addressed.length} thread(s)` : ""
267
- return json({
268
- proposed: true,
269
- proposal_id: p.id,
270
- base_version: p.base_version,
271
- note: `Submitted for review (not live)${note}.`,
659
+ edits,
660
+ baseVersion: base_version,
661
+ filename: filename ?? (edits ? undefined : "index.html"),
662
+ title,
663
+ workspaceAccess: workspace_access,
664
+ linkRole: link_role,
665
+ listed,
666
+ message,
667
+ resolves: addresses,
668
+ requestReview: request_review,
272
669
  })
670
+ } catch (e) {
671
+ return err(e instanceof Error ? e.message : "publish failed")
273
672
  }
274
- const a = await client.publish({
275
- id: short_id,
276
- content,
277
- filename: filename ?? "index.html",
278
- title,
279
- visibility,
280
- message,
281
- resolves: addresses,
282
- })
283
673
  const note = addresses?.length ? ` · resolved ${addresses.length} thread(s)` : ""
674
+ const openNote =
675
+ a.opened_in_tab === false
676
+ ? " No open Derive tab caught this push — open the url for the user if they should see it now."
677
+ : ""
284
678
  return json({
285
679
  published: true,
286
680
  short_id: a.short_id,
681
+ ...(a.review_requested ? { review_requested: true } : {}),
287
682
  version: a.current_version,
288
683
  url: a.url,
289
684
  title: a.title,
290
- note: short_id ? `Live — new version${note}.` : `Live — created "${a.title}"${note}.`,
685
+ listed: a.listed,
686
+ link_role: a.link_role,
687
+ ...(a.opened_in_tab !== undefined ? { opened_in_tab: a.opened_in_tab } : {}),
688
+ note:
689
+ (short_id ? `Live — new version${note}.` : `Live — created "${a.title}"${note}.`) +
690
+ openNote,
291
691
  })
292
692
  },
293
693
  )
@@ -304,4 +704,35 @@ server.registerResource(
304
704
  async (uri) => ({ contents: [{ uri: uri.href, mimeType: "text/markdown", text: GUIDE }] }),
305
705
  )
306
706
 
707
+ // Every account/workspace signed in on THIS machine, with the local `description`
708
+ // each was given via `derive workspace describe` — the context a bare name can't
709
+ // carry. This tool's OWN live calls only ever act as `active` below (fixed at
710
+ // startup by DERIVE_ACCOUNT/DERIVE_WORKSPACE or the stored default); the rest of
711
+ // the roster is visibility only, for deciding whether that pin is still the right
712
+ // one — e.g. before proposing a change to a project's .mcp.json. Read fresh (not
713
+ // cached at startup) since `derive workspace describe` can run in a sibling
714
+ // terminal mid-session.
715
+ server.registerResource(
716
+ "derive-workspaces",
717
+ "derive://workspaces",
718
+ {
719
+ title: "Signed-in accounts & workspaces",
720
+ description:
721
+ "Every account and workspace signed in on this machine, each with its local `description` " +
722
+ "(what it's FOR, set via `derive workspace describe`) if one has been set. `active` is the " +
723
+ "one this session's tools actually publish to — read this before assuming a bare workspace " +
724
+ "name is enough context, or before touching a project's DERIVE_ACCOUNT/DERIVE_WORKSPACE pin.",
725
+ mimeType: "application/json",
726
+ },
727
+ async (uri) => ({
728
+ contents: [
729
+ {
730
+ uri: uri.href,
731
+ mimeType: "application/json",
732
+ text: JSON.stringify(buildRoster(), null, 2),
733
+ },
734
+ ],
735
+ }),
736
+ )
737
+
307
738
  await server.connect(new StdioServerTransport())
package/LICENSE DELETED
@@ -1,105 +0,0 @@
1
- # Functional Source License, Version 1.1, ALv2 Future License
2
-
3
- ## Abbreviation
4
-
5
- FSL-1.1-ALv2
6
-
7
- ## Notice
8
-
9
- Copyright 2026 Anir Agarwal <Agarwal.anir@gmail.com>
10
-
11
- ## Terms and Conditions
12
-
13
- ### Licensor ("We")
14
-
15
- The party offering the Software under these Terms and Conditions.
16
-
17
- ### The Software
18
-
19
- The "Software" is each version of the software that we make available under
20
- these Terms and Conditions, as indicated by our inclusion of these Terms and
21
- Conditions with the Software.
22
-
23
- ### License Grant
24
-
25
- Subject to your compliance with this License Grant and the Patents,
26
- Redistribution and Trademark clauses below, we hereby grant you the right to
27
- use, copy, modify, create derivative works, publicly perform, publicly display
28
- and redistribute the Software for any Permitted Purpose identified below.
29
-
30
- ### Permitted Purpose
31
-
32
- A Permitted Purpose is any purpose other than a Competing Use. A Competing Use
33
- means making the Software available to others in a commercial product or
34
- service that:
35
-
36
- 1. substitutes for the Software;
37
-
38
- 2. substitutes for any other product or service we offer using the Software
39
- that exists as of the date we make the Software available; or
40
-
41
- 3. offers the same or substantially similar functionality as the Software.
42
-
43
- Permitted Purposes specifically include using the Software:
44
-
45
- 1. for your internal use and access;
46
-
47
- 2. for non-commercial education;
48
-
49
- 3. for non-commercial research; and
50
-
51
- 4. in connection with professional services that you provide to a licensee
52
- using the Software in accordance with these Terms and Conditions.
53
-
54
- ### Patents
55
-
56
- To the extent your use for a Permitted Purpose would necessarily infringe our
57
- patents, the license grant above includes a license under our patents. If you
58
- make a claim against any party that the Software infringes or contributes to
59
- the infringement of any patent, then your patent license to the Software ends
60
- immediately.
61
-
62
- ### Redistribution
63
-
64
- The Terms and Conditions apply to all copies, modifications and derivatives of
65
- the Software.
66
-
67
- If you redistribute any copies, modifications or derivatives of the Software,
68
- you must include a copy of or a link to these Terms and Conditions and not
69
- remove any copyright notices provided in or with the Software.
70
-
71
- ### Disclaimer
72
-
73
- THE SOFTWARE IS PROVIDED "AS IS" AND WITHOUT WARRANTIES OF ANY KIND, EXPRESS OR
74
- IMPLIED, INCLUDING WITHOUT LIMITATION WARRANTIES OF FITNESS FOR A PARTICULAR
75
- PURPOSE, MERCHANTABILITY, TITLE OR NON-INFRINGEMENT.
76
-
77
- IN NO EVENT WILL WE HAVE ANY LIABILITY TO YOU ARISING OUT OF OR RELATED TO THE
78
- SOFTWARE, INCLUDING INDIRECT, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES,
79
- EVEN IF WE HAVE BEEN INFORMED OF THEIR POSSIBILITY IN ADVANCE.
80
-
81
- ### Trademarks
82
-
83
- Except for displaying the License Details and identifying us as the origin of
84
- the Software, you have no right under these Terms and Conditions to use our
85
- trademarks, trade names, service marks or product names.
86
-
87
- ## Grant of Future License
88
-
89
- We hereby irrevocably grant you an additional license to use the Software under
90
- the Apache License, Version 2.0 that is effective on the second anniversary of
91
- the date we make the Software available. On or after that date, you may use the
92
- Software under the Apache License, Version 2.0, in which case the following
93
- will apply:
94
-
95
- Licensed under the Apache License, Version 2.0 (the "License"); you may not use
96
- this file except in compliance with the License.
97
-
98
- You may obtain a copy of the License at
99
-
100
- http://www.apache.org/licenses/LICENSE-2.0
101
-
102
- Unless required by applicable law or agreed to in writing, software distributed
103
- under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
104
- CONDITIONS OF ANY KIND, either express or implied. See the License for the
105
- specific language governing permissions and limitations under the License.