@derive-to/mcp 0.2.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.
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, 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 the exact line diff. 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. |
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
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. 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. |
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
 
@@ -54,6 +54,54 @@ human approves rather than live content.
54
54
  `addresses` + `request_review:true` for the next round. The human never
55
55
  resolves threads — you settle thread state.
56
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.
104
+
57
105
  ## Keep comments anchorable
58
106
 
59
107
  Anchors are text quotes with surrounding context, matched in the rendered
@@ -66,7 +114,18 @@ wrong place.
66
114
 
67
115
  - Versions are immutable; `@vN` URLs never change. The viewer groups rapid
68
116
  same-author revisions into time-based sessions, but every revision is addressable.
69
- - 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
70
119
  over the remote `/mcp` server via `publish` with a `files` map. Over the stdio
71
- `@derive-to/mcp` server, bundles are publish-via-remote/web only, and `comment` set_state
72
- 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.2.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": [
@@ -44,6 +44,7 @@
44
44
  "test:coverage": "vitest run --coverage"
45
45
  },
46
46
  "dependencies": {
47
+ "@derive-to/cli": "workspace:*",
47
48
  "@modelcontextprotocol/sdk": "^1.12.0",
48
49
  "tsx": "^4.19.0",
49
50
  "zod": "^4.4.3"
package/src/client.ts CHANGED
@@ -1,14 +1,25 @@
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" | "org" | "private"
11
- /** A lock on a public link (optional). */
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
@@ -16,6 +27,11 @@ export interface PublishArgs {
16
27
  resolves?: string[]
17
28
  /** Open a review round for this version (the /derive loop's ask). */
18
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
19
35
  }
20
36
 
21
37
  export type CommentState = "open" | "addressed" | "resolved" | "outdated"
@@ -39,16 +55,23 @@ export interface ArtifactSummaryJson {
39
55
  title: string | null
40
56
  kind: "file" | "bundle"
41
57
  current_version: number
42
- visibility: string
58
+ workspace_access?: string
59
+ link_role?: string
60
+ listed?: string
43
61
  }
44
62
 
45
63
  /** A revision submitted for human review instead of published live. */
46
64
  export interface ProposeArgs {
47
- content: string
65
+ /** Full content for the proposal. Omit when using `edits` instead. */
66
+ content?: string
48
67
  filename?: string
49
68
  message: string
50
69
  /** Thread ids this revision addresses (flip to `addressed`, resolve on approval). */
51
70
  addresses?: string[]
71
+ /** Exact-match search/replace against the current stored source, INSTEAD of
72
+ * `content`. */
73
+ edits?: DocEdit[]
74
+ baseVersion?: number
52
75
  }
53
76
  export interface ProposalJson {
54
77
  id: string
@@ -85,7 +108,11 @@ export interface ArtifactJson {
85
108
  url: string
86
109
  title: string | null
87
110
  kind: "file" | "bundle"
88
- 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
89
116
  current_version: number
90
117
  versions: VersionJson[]
91
118
  /** Time-grouped version view (newest-first); present on the detail endpoint. */
@@ -123,6 +150,32 @@ export interface ViewStatsJson {
123
150
  recent: { viewer: string; kind: "user" | "anon"; at: string }[]
124
151
  }
125
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
+
126
179
  export interface DeriveClient {
127
180
  /** List the workspace's artifacts (optionally filtered by a title query). */
128
181
  list(query?: string): Promise<ArtifactSummaryJson[]>
@@ -130,13 +183,20 @@ export interface DeriveClient {
130
183
  /** Submit a single-file revision for human review (does not go live). */
131
184
  propose(shortId: string, args: ProposeArgs): Promise<ProposalJson>
132
185
  get(shortId: string): Promise<ArtifactJson>
133
- 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 }>
134
193
  listComments(shortId: string, state?: CommentState): Promise<CommentJson[]>
135
194
  createComment(shortId: string, args: NewCommentArgs): Promise<CommentJson>
136
195
  /** Resolve or reopen the thread a comment belongs to. */
137
196
  setThreadState(shortId: string, commentId: string, state: "resolved" | "open"): Promise<void>
138
- /** Line diff between two versions (defaults: current-1 → current). */
139
- 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>
140
200
  /** The artifact's review rounds (newest first) + the pending one, if any. */
141
201
  getReview(
142
202
  shortId: string,
@@ -152,6 +212,11 @@ export interface DeriveClient {
152
212
  export interface ClientOptions {
153
213
  baseUrl: string
154
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
155
220
  /** Override fetch (used in tests to target an in-process server). */
156
221
  fetchImpl?: typeof fetch
157
222
  }
@@ -159,9 +224,10 @@ export interface ClientOptions {
159
224
  export function createClient(opts: ClientOptions): DeriveClient {
160
225
  const base = opts.baseUrl.replace(/\/$/, "")
161
226
  const f = opts.fetchImpl ?? fetch
162
- const authHeaders: Record<string, string> = opts.token
163
- ? { Authorization: `Bearer ${opts.token}` }
164
- : {}
227
+ const authHeaders: Record<string, string> = {
228
+ ...(opts.token ? { Authorization: `Bearer ${opts.token}` } : {}),
229
+ ...(opts.workspace ? { "X-Derive-Workspace": opts.workspace } : {}),
230
+ }
165
231
 
166
232
  async function ok(res: Response): Promise<unknown> {
167
233
  if (res.ok) return res.json()
@@ -179,14 +245,24 @@ export function createClient(opts: ClientOptions): DeriveClient {
179
245
  },
180
246
 
181
247
  async publish(args) {
182
- const bytes =
183
- typeof args.content === "string" ? new TextEncoder().encode(args.content) : args.content
184
248
  const form = new FormData()
185
- 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
+ }
186
260
  if (args.title) form.append("title", args.title)
187
261
  if (args.slug) form.append("slug", args.slug)
188
262
  if (args.message) form.append("message", args.message)
189
- 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)
190
266
  if (args.password) form.append("password", args.password)
191
267
  if (args.spa) form.append("spa", "true")
192
268
  if (args.resolves?.length) form.append("resolves", args.resolves.join(","))
@@ -199,11 +275,16 @@ export function createClient(opts: ClientOptions): DeriveClient {
199
275
 
200
276
  async propose(shortId, args) {
201
277
  const form = new FormData()
202
- form.append(
203
- "file",
204
- new Blob([new TextEncoder().encode(args.content)]),
205
- args.filename ?? "index.html",
206
- )
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
+ }
207
288
  form.append("message", args.message)
208
289
  if (args.addresses?.length) form.append("addresses", args.addresses.join(","))
209
290
  return ok(
@@ -221,14 +302,44 @@ export function createClient(opts: ClientOptions): DeriveClient {
221
302
  ) as Promise<ArtifactJson>
222
303
  },
223
304
 
224
- async getContent(shortId, version) {
225
- const q = version ? `?v=${version}` : ""
226
- 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
+ })
227
314
  if (!res.ok) {
228
315
  const body = (await res.json().catch(() => ({}))) as { error?: string }
229
316
  throw new Error(`derive ${res.status}: ${body.error ?? res.statusText}`)
230
317
  }
231
- 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 }
232
343
  },
233
344
 
234
345
  async listComments(shortId, state) {
@@ -259,10 +370,11 @@ export function createClient(opts: ClientOptions): DeriveClient {
259
370
  )
260
371
  },
261
372
 
262
- async diff(shortId, from, to) {
373
+ async diff(shortId, from, to, content) {
263
374
  const q = new URLSearchParams({ format: "json" })
264
375
  if (from != null) q.set("from", String(from))
265
376
  if (to != null) q.set("to", String(to))
377
+ if (content === "markdown") q.set("content", "markdown")
266
378
  return ok(
267
379
  await f(`${base}/v1/artifacts/${shortId}/diff?${q}`, { headers: authHeaders }),
268
380
  ) as Promise<DiffJson>
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
 
@@ -71,7 +280,7 @@ server.registerTool(
71
280
  description:
72
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. " +
75
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.",
76
285
  inputSchema: {
77
286
  short_id: z.string(),
@@ -104,9 +313,19 @@ server.registerTool(
104
313
  .describe(
105
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.",
106
315
  ),
316
+ workspace: wsArg,
107
317
  },
108
318
  },
109
- async ({ short_id, since_version, to_version, comments, response_format, wait }) => {
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)
110
329
  const summarizeComment = (c: {
111
330
  thread_id: string
112
331
  author: string
@@ -198,7 +417,9 @@ server.registerTool(
198
417
  : ""
199
418
  let entryDiff: string | undefined
200
419
  if (response_format === "detailed" && since < to) {
201
- 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")
202
423
  entryDiff = d.ops
203
424
  .map((o) => `${o.t === "add" ? "+" : o.t === "del" ? "-" : " "} ${o.line}`)
204
425
  .join("\n")
@@ -256,9 +477,11 @@ server.registerTool(
256
477
  .string()
257
478
  .optional()
258
479
  .describe("A comment in the thread to react to / set_state on (when not posting)."),
480
+ workspace: wsArg,
259
481
  },
260
482
  },
261
- async ({ short_id, body, reply_to, quote, react, set_state, comment_id }) => {
483
+ async ({ short_id, body, reply_to, quote, react, set_state, comment_id, workspace: ws }) => {
484
+ const client = clientFor(ws)
262
485
  if (!body && !set_state && !react)
263
486
  return text(
264
487
  "Provide `body` (to comment), `react` (to acknowledge), or `set_state` (to resolve/reopen).",
@@ -327,9 +550,33 @@ server.registerTool(
327
550
  "publish",
328
551
  {
329
552
  description:
330
- "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.)",
331
554
  inputSchema: {
332
- 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
+ ),
333
580
  filename: z
334
581
  .string()
335
582
  .optional()
@@ -339,9 +586,12 @@ server.registerTool(
339
586
  .optional()
340
587
  .describe("Omit to create a new artifact; pass it to add a version."),
341
588
  title: z.string().optional(),
342
- // Omitted the workspace's agent default (usually `private` the
343
- // human you act for owns the draft and promotes it when ready).
344
- visibility: z.enum(["private", "org", "public"]).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(),
345
595
  message: z.string().optional().describe("What changed in this version."),
346
596
  for_review: z
347
597
  .boolean()
@@ -357,45 +607,69 @@ server.registerTool(
357
607
  .describe(
358
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.",
359
609
  ),
610
+ workspace: wsArg,
360
611
  },
361
612
  },
362
613
  async ({
363
614
  content,
615
+ edits,
616
+ base_version,
364
617
  filename,
365
618
  short_id,
366
619
  title,
367
- visibility,
620
+ workspace_access,
621
+ link_role,
622
+ listed,
368
623
  message,
369
624
  for_review,
370
625
  addresses,
371
626
  request_review,
627
+ workspace: ws,
372
628
  }) => {
629
+ const client = clientFor(ws)
630
+ if (content !== undefined && edits) return text("Provide `content` OR `edits`, not both.")
373
631
  if (for_review) {
374
632
  if (!short_id) return text("A proposal revises an EXISTING artifact — pass its short_id.")
375
- 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,
376
658
  content,
377
- filename,
378
- message: message ?? "Proposed revision",
379
- addresses,
380
- })
381
- const note = p.addressed?.length ? ` · addressed ${p.addressed.length} thread(s)` : ""
382
- return json({
383
- proposed: true,
384
- proposal_id: p.id,
385
- base_version: p.base_version,
386
- 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,
387
669
  })
670
+ } catch (e) {
671
+ return err(e instanceof Error ? e.message : "publish failed")
388
672
  }
389
- const a = await client.publish({
390
- id: short_id,
391
- content,
392
- filename: filename ?? "index.html",
393
- title,
394
- visibility,
395
- message,
396
- resolves: addresses,
397
- requestReview: request_review,
398
- })
399
673
  const note = addresses?.length ? ` · resolved ${addresses.length} thread(s)` : ""
400
674
  const openNote =
401
675
  a.opened_in_tab === false
@@ -408,7 +682,8 @@ server.registerTool(
408
682
  version: a.current_version,
409
683
  url: a.url,
410
684
  title: a.title,
411
- visibility: a.visibility,
685
+ listed: a.listed,
686
+ link_role: a.link_role,
412
687
  ...(a.opened_in_tab !== undefined ? { opened_in_tab: a.opened_in_tab } : {}),
413
688
  note:
414
689
  (short_id ? `Live — new version${note}.` : `Live — created "${a.title}"${note}.`) +
@@ -429,4 +704,35 @@ server.registerResource(
429
704
  async (uri) => ({ contents: [{ uri: uri.href, mimeType: "text/markdown", text: GUIDE }] }),
430
705
  )
431
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
+
432
738
  await server.connect(new StdioServerTransport())