@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 +2 -2
- package/package.json +5 -3
- package/src/client.ts +35 -31
- package/src/index.ts +53 -8
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.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.
|
|
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": "
|
|
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). */
|
|
@@ -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,
|
|
@@ -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
|
|
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(
|
|
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
|
|
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:
|
|
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
|
)
|