@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 +2 -2
- package/package.json +7 -5
- package/src/client.ts +36 -32
- package/src/index.ts +58 -10
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;
|
|
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.
|
|
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.
|
|
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": "
|
|
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?:
|
|
20
|
-
linkRole?:
|
|
21
|
-
listed?:
|
|
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
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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
|
-
|
|
283
|
-
"
|
|
284
|
-
|
|
285
|
-
|
|
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(
|
|
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
|
|
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(
|
|
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
|
|
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:
|
|
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
|
)
|