@derive-to/mcp 0.4.1 → 0.5.1

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, 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. |
25
+ | `read` | Read an artifact's content by short id, as **Markdown by default** (HTML is converted — headings, lists, tables, code fences; a styled page still renders fully to viewers, only this reading view flattens it). 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
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, 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. |
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. Fully-styled HTML renders as-authored in the sandboxed viewer: declare your own `<meta name="viewport">` (skips the mobile-reflow injection; `data-reflow-exempt` exempts a single element), upload images/woff2 fonts to `POST /v1/assets` instead of inlining base64, and check the echoed `content_sha256` against your local bytes. |
29
29
 
30
30
  ## Role decides: live publish vs proposal
31
31
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@derive-to/mcp",
3
- "version": "0.4.1",
3
+ "version": "0.5.1",
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": [
@@ -41,20 +41,22 @@
41
41
  "@modelcontextprotocol/sdk": "^1.12.0",
42
42
  "tsx": "^4.19.0",
43
43
  "zod": "^4.4.3",
44
- "@derive-to/cli": "0.3.0"
44
+ "@derive-to/cli": "0.4.0"
45
45
  },
46
46
  "devDependencies": {
47
47
  "@hono/node-server": "^2.0.5",
48
48
  "@types/node": "^25.9.3",
49
49
  "typescript": "^6.0.3",
50
50
  "vitest": "^4.1.9",
51
+ "@derive/api": "0.1.0",
52
+ "@derive/core": "0.1.0",
51
53
  "@derive/db": "0.1.0",
52
- "@derive/storage": "0.1.0",
53
- "@derive/api": "0.1.0"
54
+ "@derive/storage": "0.1.0"
54
55
  },
55
56
  "scripts": {
56
57
  "start": "tsx src/index.ts",
57
- "typecheck": "tsc --noEmit",
58
+ "typecheck": "tsgo --noEmit",
59
+ "typecheck:tsc": "tsc --noEmit",
58
60
  "test": "vitest run",
59
61
  "test:coverage": "vitest run --coverage"
60
62
  }
package/src/client.ts CHANGED
@@ -1,5 +1,8 @@
1
1
  /** HTTP client for a Derive server. Shared by the MCP server and any tooling. */
2
2
 
3
+ import type { LinkRole, Listed, WorkspaceAccess } from "@derive/core"
4
+ import { buildPublishForm } from "@derive-to/cli/publish"
5
+
3
6
  /** One exact-match search/replace edit (the Edit-tool contract). */
4
7
  export interface DocEdit {
5
8
  old_str: string
@@ -16,9 +19,9 @@ export interface PublishArgs {
16
19
  message?: string
17
20
  /** The v2 access triple for a NEW artifact (see access-model.md); ignored on a
18
21
  * republish. */
19
- workspaceAccess?: "none" | "member"
20
- linkRole?: "none" | "viewer" | "commenter" | "editor"
21
- listed?: "none" | "workspace" | "public"
22
+ workspaceAccess?: WorkspaceAccess
23
+ linkRole?: LinkRole
24
+ listed?: Listed
22
25
  /** A lock on the world link (optional). */
23
26
  password?: string
24
27
  /** When set, publishes a new version of this artifact instead of a new one. */
@@ -63,7 +66,7 @@ export interface ArtifactSummaryJson {
63
66
  /** A revision submitted for human review instead of published live. */
64
67
  export interface ProposeArgs {
65
68
  /** Full content for the proposal. Omit when using `edits` instead. */
66
- content?: string
69
+ content?: string | Uint8Array
67
70
  filename?: string
68
71
  message: string
69
72
  /** Thread ids this revision addresses (flip to `addressed`, resolve on approval). */
@@ -154,7 +157,7 @@ export interface ContentOpts {
154
157
  version?: number
155
158
  /** A heading slug (single-file) or page path (bundle, optionally page#slug). */
156
159
  section?: string
157
- format?: "markdown" | "text"
160
+ format?: "markdown" | "text" | "html"
158
161
  }
159
162
 
160
163
  /** A content read: the body plus the server's X-Derive-* capability headers, so a
@@ -245,28 +248,29 @@ export function createClient(opts: ClientOptions): DeriveClient {
245
248
  },
246
249
 
247
250
  async publish(args) {
248
- const form = new FormData()
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
- }
260
- if (args.title) form.append("title", args.title)
261
- if (args.slug) form.append("slug", args.slug)
262
- if (args.message) form.append("message", args.message)
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)
266
- if (args.password) form.append("password", args.password)
267
- if (args.spa) form.append("spa", "true")
268
- if (args.resolves?.length) form.append("resolves", args.resolves.join(","))
269
- if (args.requestReview) form.append("request_review", "true")
251
+ // Surgical revision (args.edits) needs no file upload — the server materializes
252
+ // it from the current stored source (requires an existing artifact, args.id).
253
+ const bytes = args.edits
254
+ ? undefined
255
+ : typeof args.content === "string"
256
+ ? new TextEncoder().encode(args.content)
257
+ : args.content
258
+ const form = buildPublishForm({
259
+ bytes: bytes as Uint8Array | undefined,
260
+ filename: args.filename,
261
+ edits: args.edits,
262
+ baseVersion: args.baseVersion,
263
+ title: args.title,
264
+ slug: args.slug,
265
+ spa: args.spa,
266
+ message: args.message,
267
+ workspaceAccess: args.workspaceAccess,
268
+ linkRole: args.linkRole,
269
+ listed: args.listed,
270
+ password: args.password,
271
+ resolves: args.resolves,
272
+ requestReview: args.requestReview,
273
+ })
270
274
  const url = args.id ? `${base}/v1/artifacts/${args.id}/versions` : `${base}/v1/artifacts`
271
275
  return ok(
272
276
  await f(url, { method: "POST", body: form, headers: authHeaders }),
@@ -279,11 +283,11 @@ export function createClient(opts: ClientOptions): DeriveClient {
279
283
  form.append("edits", JSON.stringify(args.edits))
280
284
  if (args.baseVersion != null) form.append("base_version", String(args.baseVersion))
281
285
  } else {
282
- form.append(
283
- "file",
284
- new Blob([new TextEncoder().encode(args.content ?? "")]),
285
- args.filename ?? "index.html",
286
- )
286
+ const bytes =
287
+ typeof args.content === "string" || args.content === undefined
288
+ ? new TextEncoder().encode(args.content ?? "")
289
+ : args.content
290
+ form.append("file", new Blob([bytes as BlobPart]), args.filename ?? "index.html")
287
291
  }
288
292
  form.append("message", args.message)
289
293
  if (args.addresses?.length) form.append("addresses", args.addresses.join(","))
package/src/index.ts CHANGED
@@ -1,4 +1,6 @@
1
+ import { createHash } from "node:crypto"
1
2
  import { readFileSync } from "node:fs"
3
+ import { basename } from "node:path"
2
4
  import { fileURLToPath } from "node:url"
3
5
  import {
4
6
  findAccountWorkspace,
@@ -212,9 +214,11 @@ server.registerTool(
212
214
  'A heading slug (single-file) or page path (bundle, optionally page#slug). Pass "*" for the full document.',
213
215
  ),
214
216
  format: z
215
- .enum(["markdown", "text"])
217
+ .enum(["markdown", "text", "html"])
216
218
  .optional()
217
- .describe("markdown (default, HTML converted) or text (flat visible text)."),
219
+ .describe(
220
+ "markdown (default, HTML converted), text (flat visible text), or html (the exact stored source — read it before revising with publish `edits`).",
221
+ ),
218
222
  version: z.number().int().optional().describe("Defaults to the current version."),
219
223
  workspace: wsArg,
220
224
  },
@@ -550,12 +554,20 @@ server.registerTool(
550
554
  "publish",
551
555
  {
552
556
  description:
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.)",
557
+ "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). Provide the body as `content_path` (a local file this server reads and uploads — preferred, zero tokens) or `content` (inline text). To CHANGE PART of an existing artifact, prefer `edits` (exact-match search/replace against the stored source — read format:'html' first) over resending everything. 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.) FULLY-STYLED HTML renders as-authored (own <style>/scripts/fonts) in the sandboxed viewer — declare your own <meta name=\"viewport\"> to skip the mobile-reflow injection, and self-host binaries via POST /v1/assets (images and woff2 fonts) instead of inlining base64.",
554
558
  inputSchema: {
555
559
  content: z
556
560
  .string()
557
561
  .optional()
558
- .describe("The artifact's full text content (HTML or Markdown). Use this OR `edits`."),
562
+ .describe(
563
+ "The artifact's full text content (HTML or Markdown). Use this OR `content_path` OR `edits`. For images or web fonts, upload the raw bytes to POST /v1/assets (no base64 — binaries carried through a tool call can be silently mistranscribed) and reference the returned URL.",
564
+ ),
565
+ content_path: z
566
+ .string()
567
+ .optional()
568
+ .describe(
569
+ "PREFERRED over `content` when the artifact exists as a local file: an absolute path this server reads and uploads as raw bytes — the content never rides through your context (no token cost, no transcription risk), and the stored bytes are verified against the file's sha256 automatically. Build and iterate on the file locally, then publish it by path. Filename defaults to the file's basename.",
570
+ ),
559
571
  edits: z
560
572
  .array(
561
573
  z.object({
@@ -612,6 +624,7 @@ server.registerTool(
612
624
  },
613
625
  async ({
614
626
  content,
627
+ content_path,
615
628
  edits,
616
629
  base_version,
617
630
  filename,
@@ -627,15 +640,31 @@ server.registerTool(
627
640
  workspace: ws,
628
641
  }) => {
629
642
  const client = clientFor(ws)
630
- if (content !== undefined && edits) return text("Provide `content` OR `edits`, not both.")
643
+ if ([content, content_path, edits].filter((v) => v !== undefined).length > 1)
644
+ return text("Provide exactly one of `content`, `content_path`, or `edits`.")
645
+ // content_path: this server runs on the caller's machine, so it reads the file
646
+ // itself and uploads the bytes — the content never passes through the model. The
647
+ // local file's sha256 is checked against the response's content_sha256 echo below.
648
+ let pathBytes: Uint8Array | undefined
649
+ let pathSha: string | undefined
650
+ if (content_path !== undefined) {
651
+ try {
652
+ pathBytes = new Uint8Array(readFileSync(content_path))
653
+ } catch (e) {
654
+ return err(
655
+ `could not read content_path "${content_path}": ${e instanceof Error ? e.message : "unknown error"}`,
656
+ )
657
+ }
658
+ pathSha = createHash("sha256").update(pathBytes).digest("hex")
659
+ }
631
660
  if (for_review) {
632
661
  if (!short_id) return text("A proposal revises an EXISTING artifact — pass its short_id.")
633
662
  try {
634
663
  const p = await client.propose(short_id, {
635
- content,
664
+ content: pathBytes ?? content,
636
665
  edits,
637
666
  baseVersion: base_version,
638
- filename,
667
+ filename: filename ?? (content_path !== undefined ? basename(content_path) : undefined),
639
668
  message: message ?? "Proposed revision",
640
669
  addresses,
641
670
  })
@@ -655,10 +684,12 @@ server.registerTool(
655
684
  try {
656
685
  a = await client.publish({
657
686
  id: short_id,
658
- content,
687
+ content: pathBytes ?? content,
659
688
  edits,
660
689
  baseVersion: base_version,
661
- filename: filename ?? (edits ? undefined : "index.html"),
690
+ filename:
691
+ filename ??
692
+ (content_path !== undefined ? basename(content_path) : edits ? undefined : "index.html"),
662
693
  title,
663
694
  workspaceAccess: workspace_access,
664
695
  linkRole: link_role,
@@ -670,15 +701,31 @@ server.registerTool(
670
701
  } catch (e) {
671
702
  return err(e instanceof Error ? e.message : "publish failed")
672
703
  }
704
+ // The server echoes the sha256 of the stored bytes; for a by-path publish it
705
+ // must match the local file exactly.
706
+ const echoedSha = (a as { content_sha256?: string }).content_sha256
707
+ if (pathSha && echoedSha && echoedSha !== pathSha)
708
+ return err(
709
+ `content integrity mismatch: local file sha256 ${pathSha} but the server stored ${echoedSha} — the upload was corrupted; retry the publish.`,
710
+ )
673
711
  const note = addresses?.length ? ` · resolved ${addresses.length} thread(s)` : ""
674
712
  const openNote =
675
713
  a.opened_in_tab === false
676
714
  ? " No open Derive tab caught this push — open the url for the user if they should see it now."
677
715
  : ""
716
+ // Advisories (missing viewport, oversized inline base64) are computed server-side
717
+ // and carried on the REST response; this shim is an HTTP client with no @derive/core
718
+ // at runtime, so it only relays them.
719
+ const advisories = (a as { advisories?: string[] }).advisories
720
+ const advisoryNote = advisories?.length
721
+ ? advisories.map((advisory) => ` ${advisory}`).join("")
722
+ : ""
678
723
  return json({
679
724
  published: true,
680
725
  short_id: a.short_id,
681
726
  ...(a.review_requested ? { review_requested: true } : {}),
727
+ ...(echoedSha ? { content_sha256: echoedSha } : {}),
728
+ ...(pathSha && echoedSha ? { content_verified: true } : {}),
682
729
  version: a.current_version,
683
730
  url: a.url,
684
731
  title: a.title,
@@ -687,7 +734,8 @@ server.registerTool(
687
734
  ...(a.opened_in_tab !== undefined ? { opened_in_tab: a.opened_in_tab } : {}),
688
735
  note:
689
736
  (short_id ? `Live — new version${note}.` : `Live — created "${a.title}"${note}.`) +
690
- openNote,
737
+ openNote +
738
+ advisoryNote,
691
739
  })
692
740
  },
693
741
  )