@derive-to/mcp 0.4.1 → 0.5.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, 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.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": [
@@ -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/core": "0.1.0",
51
52
  "@derive/db": "0.1.0",
52
53
  "@derive/storage": "0.1.0",
53
54
  "@derive/api": "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). */
@@ -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,
@@ -550,12 +552,20 @@ server.registerTool(
550
552
  "publish",
551
553
  {
552
554
  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.)",
555
+ "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
556
  inputSchema: {
555
557
  content: z
556
558
  .string()
557
559
  .optional()
558
- .describe("The artifact's full text content (HTML or Markdown). Use this OR `edits`."),
560
+ .describe(
561
+ "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.",
562
+ ),
563
+ content_path: z
564
+ .string()
565
+ .optional()
566
+ .describe(
567
+ "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.",
568
+ ),
559
569
  edits: z
560
570
  .array(
561
571
  z.object({
@@ -612,6 +622,7 @@ server.registerTool(
612
622
  },
613
623
  async ({
614
624
  content,
625
+ content_path,
615
626
  edits,
616
627
  base_version,
617
628
  filename,
@@ -627,15 +638,31 @@ server.registerTool(
627
638
  workspace: ws,
628
639
  }) => {
629
640
  const client = clientFor(ws)
630
- if (content !== undefined && edits) return text("Provide `content` OR `edits`, not both.")
641
+ if ([content, content_path, edits].filter((v) => v !== undefined).length > 1)
642
+ return text("Provide exactly one of `content`, `content_path`, or `edits`.")
643
+ // content_path: this server runs on the caller's machine, so it reads the file
644
+ // itself and uploads the bytes — the content never passes through the model. The
645
+ // local file's sha256 is checked against the response's content_sha256 echo below.
646
+ let pathBytes: Uint8Array | undefined
647
+ let pathSha: string | undefined
648
+ if (content_path !== undefined) {
649
+ try {
650
+ pathBytes = new Uint8Array(readFileSync(content_path))
651
+ } catch (e) {
652
+ return err(
653
+ `could not read content_path "${content_path}": ${e instanceof Error ? e.message : "unknown error"}`,
654
+ )
655
+ }
656
+ pathSha = createHash("sha256").update(pathBytes).digest("hex")
657
+ }
631
658
  if (for_review) {
632
659
  if (!short_id) return text("A proposal revises an EXISTING artifact — pass its short_id.")
633
660
  try {
634
661
  const p = await client.propose(short_id, {
635
- content,
662
+ content: pathBytes ?? content,
636
663
  edits,
637
664
  baseVersion: base_version,
638
- filename,
665
+ filename: filename ?? (content_path !== undefined ? basename(content_path) : undefined),
639
666
  message: message ?? "Proposed revision",
640
667
  addresses,
641
668
  })
@@ -655,10 +682,12 @@ server.registerTool(
655
682
  try {
656
683
  a = await client.publish({
657
684
  id: short_id,
658
- content,
685
+ content: pathBytes ?? content,
659
686
  edits,
660
687
  baseVersion: base_version,
661
- filename: filename ?? (edits ? undefined : "index.html"),
688
+ filename:
689
+ filename ??
690
+ (content_path !== undefined ? basename(content_path) : edits ? undefined : "index.html"),
662
691
  title,
663
692
  workspaceAccess: workspace_access,
664
693
  linkRole: link_role,
@@ -670,15 +699,30 @@ server.registerTool(
670
699
  } catch (e) {
671
700
  return err(e instanceof Error ? e.message : "publish failed")
672
701
  }
702
+ // The server echoes the sha256 of the stored bytes; for a by-path publish it
703
+ // must match the local file exactly.
704
+ const echoedSha = (a as { content_sha256?: string }).content_sha256
705
+ if (pathSha && echoedSha && echoedSha !== pathSha)
706
+ return err(
707
+ `content integrity mismatch: local file sha256 ${pathSha} but the server stored ${echoedSha} — the upload was corrupted; retry the publish.`,
708
+ )
673
709
  const note = addresses?.length ? ` · resolved ${addresses.length} thread(s)` : ""
674
710
  const openNote =
675
711
  a.opened_in_tab === false
676
712
  ? " No open Derive tab caught this push — open the url for the user if they should see it now."
677
713
  : ""
714
+ // Advisories (missing viewport, oversized inline base64) are computed server-side
715
+ // and carried on the REST response; this shim is an HTTP client with no @derive/core
716
+ // at runtime, so it only relays them.
717
+ const advisories = (a as { advisories?: string[] }).advisories
718
+ const advisoryNote = advisories?.length
719
+ ? advisories.map((advisory) => ` ${advisory}`).join("")
720
+ : ""
678
721
  return json({
679
722
  published: true,
680
723
  short_id: a.short_id,
681
724
  ...(a.review_requested ? { review_requested: true } : {}),
725
+ ...(pathSha && echoedSha ? { content_verified: true } : {}),
682
726
  version: a.current_version,
683
727
  url: a.url,
684
728
  title: a.title,
@@ -687,7 +731,8 @@ server.registerTool(
687
731
  ...(a.opened_in_tab !== undefined ? { opened_in_tab: a.opened_in_tab } : {}),
688
732
  note:
689
733
  (short_id ? `Live — new version${note}.` : `Live — created "${a.title}"${note}.`) +
690
- openNote,
734
+ openNote +
735
+ advisoryNote,
691
736
  })
692
737
  },
693
738
  )