@derive-to/mcp 0.1.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/LICENSE ADDED
@@ -0,0 +1,105 @@
1
+ # Functional Source License, Version 1.1, ALv2 Future License
2
+
3
+ ## Abbreviation
4
+
5
+ FSL-1.1-ALv2
6
+
7
+ ## Notice
8
+
9
+ Copyright 2026 Anir Agarwal <Agarwal.anir@gmail.com>
10
+
11
+ ## Terms and Conditions
12
+
13
+ ### Licensor ("We")
14
+
15
+ The party offering the Software under these Terms and Conditions.
16
+
17
+ ### The Software
18
+
19
+ The "Software" is each version of the software that we make available under
20
+ these Terms and Conditions, as indicated by our inclusion of these Terms and
21
+ Conditions with the Software.
22
+
23
+ ### License Grant
24
+
25
+ Subject to your compliance with this License Grant and the Patents,
26
+ Redistribution and Trademark clauses below, we hereby grant you the right to
27
+ use, copy, modify, create derivative works, publicly perform, publicly display
28
+ and redistribute the Software for any Permitted Purpose identified below.
29
+
30
+ ### Permitted Purpose
31
+
32
+ A Permitted Purpose is any purpose other than a Competing Use. A Competing Use
33
+ means making the Software available to others in a commercial product or
34
+ service that:
35
+
36
+ 1. substitutes for the Software;
37
+
38
+ 2. substitutes for any other product or service we offer using the Software
39
+ that exists as of the date we make the Software available; or
40
+
41
+ 3. offers the same or substantially similar functionality as the Software.
42
+
43
+ Permitted Purposes specifically include using the Software:
44
+
45
+ 1. for your internal use and access;
46
+
47
+ 2. for non-commercial education;
48
+
49
+ 3. for non-commercial research; and
50
+
51
+ 4. in connection with professional services that you provide to a licensee
52
+ using the Software in accordance with these Terms and Conditions.
53
+
54
+ ### Patents
55
+
56
+ To the extent your use for a Permitted Purpose would necessarily infringe our
57
+ patents, the license grant above includes a license under our patents. If you
58
+ make a claim against any party that the Software infringes or contributes to
59
+ the infringement of any patent, then your patent license to the Software ends
60
+ immediately.
61
+
62
+ ### Redistribution
63
+
64
+ The Terms and Conditions apply to all copies, modifications and derivatives of
65
+ the Software.
66
+
67
+ If you redistribute any copies, modifications or derivatives of the Software,
68
+ you must include a copy of or a link to these Terms and Conditions and not
69
+ remove any copyright notices provided in or with the Software.
70
+
71
+ ### Disclaimer
72
+
73
+ THE SOFTWARE IS PROVIDED "AS IS" AND WITHOUT WARRANTIES OF ANY KIND, EXPRESS OR
74
+ IMPLIED, INCLUDING WITHOUT LIMITATION WARRANTIES OF FITNESS FOR A PARTICULAR
75
+ PURPOSE, MERCHANTABILITY, TITLE OR NON-INFRINGEMENT.
76
+
77
+ IN NO EVENT WILL WE HAVE ANY LIABILITY TO YOU ARISING OUT OF OR RELATED TO THE
78
+ SOFTWARE, INCLUDING INDIRECT, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES,
79
+ EVEN IF WE HAVE BEEN INFORMED OF THEIR POSSIBILITY IN ADVANCE.
80
+
81
+ ### Trademarks
82
+
83
+ Except for displaying the License Details and identifying us as the origin of
84
+ the Software, you have no right under these Terms and Conditions to use our
85
+ trademarks, trade names, service marks or product names.
86
+
87
+ ## Grant of Future License
88
+
89
+ We hereby irrevocably grant you an additional license to use the Software under
90
+ the Apache License, Version 2.0 that is effective on the second anniversary of
91
+ the date we make the Software available. On or after that date, you may use the
92
+ Software under the Apache License, Version 2.0, in which case the following
93
+ will apply:
94
+
95
+ Licensed under the Apache License, Version 2.0 (the "License"); you may not use
96
+ this file except in compliance with the License.
97
+
98
+ You may obtain a copy of the License at
99
+
100
+ http://www.apache.org/licenses/LICENSE-2.0
101
+
102
+ Unless required by applicable law or agreed to in writing, software distributed
103
+ under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
104
+ CONDITIONS OF ANY KIND, either express or implied. See the License for the
105
+ specific language governing permissions and limitations under the License.
package/SKILL.md ADDED
@@ -0,0 +1,65 @@
1
+ # Derive — agent skill
2
+
3
+ Derive hosts an artifact (HTML, Markdown, or a static bundle) at a permanent,
4
+ versioned URL with inline comments. An agent connects to Derive's **remote MCP
5
+ server** over Streamable HTTP (OAuth-authenticated — no static token) and drives
6
+ the **read → revise → publish** loop. The agent acts at **its own role**: what it
7
+ can do is exactly what that role can do.
8
+
9
+ Connect (Claude Code / claude.ai / Claude Desktop):
10
+
11
+ ```
12
+ claude mcp add --transport http derive <your-derive-server>/mcp
13
+ ```
14
+
15
+ The first call triggers an OAuth consent in the browser; you grant the agent a
16
+ scope, and that scope maps to a role (read-only, propose, or publish).
17
+
18
+ Your identity (agent name, workspace, role) is in the server instructions — there is no `whoami` tool.
19
+
20
+ ## Tools
21
+
22
+ | Tool | Use |
23
+ |---|---|
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, 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. |
27
+ | `comment` | Leave feedback, reply (`reply_to` a thread id), anchor to a `quote`, 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. |
29
+
30
+ ## Role decides: live publish vs proposal
31
+
32
+ `publish` is one tool. Whether it goes live or files a proposal is decided by **your role** (the scope you were granted), with `for_review:true` to force review:
33
+
34
+ - **Admin / Creator** → `publish` goes live immediately, or pass `for_review:true` to file a proposal instead.
35
+ - **Commenter** → `publish` files a proposal a human approves before it goes live.
36
+ - **Viewer** → read-only.
37
+
38
+ So an agent you authorize with a publish scope publishes exactly as you would; a
39
+ lower-scoped agent can still read and publish, but its revisions become proposals a
40
+ human approves rather than live content.
41
+
42
+ ## The loop
43
+
44
+ 1. **`catch_up`** → what changed since you last looked, plus the open feedback to address.
45
+ 2. **`read`** → the content (a page of a bundle, or a past version) in context.
46
+ 3. **Revise**, then **`comment`** (reply/resolve) and/or **`publish`** (pass `addresses`
47
+ to resolve the threads this revision fixes) — same URL, a new version. Comment
48
+ highlights re-anchor to the moved text.
49
+
50
+ ## Keep comments anchorable
51
+
52
+ Anchors are text quotes with surrounding context, matched in the rendered
53
+ document. They survive edits when the text stays recognizable: make local edits,
54
+ keep headings and distinctive phrases stable, and prefer real text over images of
55
+ text. A comment whose text is gone is shown as "text changed", never moved to the
56
+ wrong place.
57
+
58
+ ## Notes
59
+
60
+ - Versions are immutable; `@vN` URLs never change. The viewer groups rapid
61
+ same-author revisions into time-based sessions, but every revision is addressable.
62
+ - Multi-page bundles are readable (`read` with a `section`, `catch_up`) and revisable
63
+ over the remote `/mcp` server via `publish` with a `files` map. Over the stdio
64
+ `@derive-to/mcp` server, bundles are publish-via-remote/web only, and `comment` set_state
65
+ takes a `comment_id`. Both servers expose the same 5 tools.
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env node
2
+ // `npx @derive-to/mcp`: start the Derive MCP stdio server. Derive ships TypeScript with no
3
+ // build step, so register tsx's loader and import the TS entry. The server talks
4
+ // to a Derive instance over HTTP (DERIVE_SERVER) with an optional bearer (DERIVE_TOKEN).
5
+ import { register } from "tsx/esm/api"
6
+
7
+ register()
8
+ await import(new URL("../src/index.ts", import.meta.url).href)
package/package.json ADDED
@@ -0,0 +1,60 @@
1
+ {
2
+ "name": "@derive-to/mcp",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Stdio MCP server for Derive — list, read, catch up on, comment on, and publish artifacts on a Derive instance.",
6
+ "keywords": [
7
+ "mcp",
8
+ "model-context-protocol",
9
+ "derive",
10
+ "artifacts"
11
+ ],
12
+ "license": "FSL-1.1-ALv2",
13
+ "homepage": "https://derive.to",
14
+ "bugs": {
15
+ "url": "https://github.com/derive-to/derive/issues"
16
+ },
17
+ "engines": {
18
+ "node": ">=20"
19
+ },
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "https://github.com/derive-to/derive.git",
23
+ "directory": "packages/mcp"
24
+ },
25
+ "bin": {
26
+ "derive-mcp": "./bin/derive-mcp.mjs"
27
+ },
28
+ "files": [
29
+ "bin",
30
+ "src",
31
+ "SKILL.md"
32
+ ],
33
+ "publishConfig": {
34
+ "access": "public"
35
+ },
36
+ "exports": {
37
+ ".": "./src/index.ts",
38
+ "./client": "./src/client.ts"
39
+ },
40
+ "dependencies": {
41
+ "@modelcontextprotocol/sdk": "^1.12.0",
42
+ "tsx": "^4.19.0",
43
+ "zod": "^4.4.3"
44
+ },
45
+ "devDependencies": {
46
+ "@hono/node-server": "^2.0.5",
47
+ "@types/node": "^25.9.3",
48
+ "typescript": "^6.0.3",
49
+ "vitest": "^4.1.9",
50
+ "@derive/api": "0.1.0",
51
+ "@derive/db": "0.1.0",
52
+ "@derive/storage": "0.1.0"
53
+ },
54
+ "scripts": {
55
+ "start": "tsx src/index.ts",
56
+ "typecheck": "tsc --noEmit",
57
+ "test": "vitest run",
58
+ "test:coverage": "vitest run --coverage"
59
+ }
60
+ }
package/src/client.ts ADDED
@@ -0,0 +1,263 @@
1
+ /** HTTP client for a Derive server. Shared by the MCP server and any tooling. */
2
+
3
+ export interface PublishArgs {
4
+ content: string | Uint8Array
5
+ filename: string
6
+ title?: string
7
+ slug?: string
8
+ spa?: boolean
9
+ message?: string
10
+ visibility?: "public" | "link" | "org" | "password" | "private"
11
+ /** Unlock password, required when visibility is "password". */
12
+ password?: string
13
+ /** When set, publishes a new version of this artifact instead of a new one. */
14
+ id?: string
15
+ /** Comment ids whose threads to resolve as part of this (re)publish. */
16
+ resolves?: string[]
17
+ }
18
+
19
+ export type CommentState = "open" | "addressed" | "resolved" | "outdated"
20
+
21
+ export interface CommentJson {
22
+ id: string
23
+ thread_id: string
24
+ base_version: number
25
+ path: string | null
26
+ anchor: string | null
27
+ body_md: string
28
+ author: string
29
+ state: CommentState
30
+ created_at: string
31
+ }
32
+
33
+ export interface ArtifactSummaryJson {
34
+ short_id: string
35
+ title: string | null
36
+ kind: "file" | "bundle"
37
+ current_version: number
38
+ visibility: string
39
+ }
40
+
41
+ /** A revision submitted for human review instead of published live. */
42
+ export interface ProposeArgs {
43
+ content: string
44
+ filename?: string
45
+ message: string
46
+ /** Thread ids this revision addresses (flip to `addressed`, resolve on approval). */
47
+ addresses?: string[]
48
+ }
49
+ export interface ProposalJson {
50
+ id: string
51
+ base_version: number
52
+ addressed?: string[]
53
+ }
54
+
55
+ export interface NewCommentArgs {
56
+ body_md: string
57
+ thread_id?: string
58
+ author?: string
59
+ base_version?: number
60
+ path?: string
61
+ anchor?: unknown
62
+ }
63
+
64
+ export interface VersionJson {
65
+ n: number
66
+ author: string
67
+ message: string | null
68
+ name: string | null
69
+ created_at: string
70
+ }
71
+ export interface SessionJson {
72
+ n: number
73
+ from_n: number
74
+ count: number
75
+ author: string
76
+ name: string | null
77
+ created_at: string
78
+ }
79
+ export interface ArtifactJson {
80
+ short_id: string
81
+ url: string
82
+ title: string | null
83
+ kind: "file" | "bundle"
84
+ visibility: string
85
+ current_version: number
86
+ versions: VersionJson[]
87
+ /** Time-grouped version view (newest-first); present on the detail endpoint. */
88
+ sessions?: SessionJson[]
89
+ }
90
+
91
+ export interface DiffOpJson {
92
+ t: "ctx" | "add" | "del"
93
+ line: string
94
+ }
95
+ export interface DiffJson {
96
+ from: number
97
+ to: number
98
+ ops: DiffOpJson[]
99
+ }
100
+
101
+ export interface ViewStatsJson {
102
+ total: number
103
+ unique: number
104
+ perVersion: { version: number; count: number }[]
105
+ daily: { day: string; count: number }[]
106
+ recent: { viewer: string; kind: "user" | "anon"; at: string }[]
107
+ }
108
+
109
+ export interface DeriveClient {
110
+ /** List the workspace's artifacts (optionally filtered by a title query). */
111
+ list(query?: string): Promise<ArtifactSummaryJson[]>
112
+ publish(args: PublishArgs): Promise<ArtifactJson>
113
+ /** Submit a single-file revision for human review (does not go live). */
114
+ propose(shortId: string, args: ProposeArgs): Promise<ProposalJson>
115
+ get(shortId: string): Promise<ArtifactJson>
116
+ getContent(shortId: string, version?: number): Promise<string>
117
+ listComments(shortId: string, state?: CommentState): Promise<CommentJson[]>
118
+ createComment(shortId: string, args: NewCommentArgs): Promise<CommentJson>
119
+ /** Resolve or reopen the thread a comment belongs to. */
120
+ setThreadState(shortId: string, commentId: string, state: "resolved" | "open"): Promise<void>
121
+ /** Line diff between two versions (defaults: current-1 → current). */
122
+ diff(shortId: string, from?: number, to?: number): Promise<DiffJson>
123
+ /** Restore a past version as a new current revision. */
124
+ restore(shortId: string, version: number): Promise<ArtifactJson>
125
+ /** Aggregated view analytics. */
126
+ viewStats(shortId: string): Promise<ViewStatsJson>
127
+ }
128
+
129
+ export interface ClientOptions {
130
+ baseUrl: string
131
+ token?: string
132
+ /** Override fetch (used in tests to target an in-process server). */
133
+ fetchImpl?: typeof fetch
134
+ }
135
+
136
+ export function createClient(opts: ClientOptions): DeriveClient {
137
+ const base = opts.baseUrl.replace(/\/$/, "")
138
+ const f = opts.fetchImpl ?? fetch
139
+ const authHeaders: Record<string, string> = opts.token
140
+ ? { Authorization: `Bearer ${opts.token}` }
141
+ : {}
142
+
143
+ async function ok(res: Response): Promise<unknown> {
144
+ if (res.ok) return res.json()
145
+ const body = (await res.json().catch(() => ({}))) as { error?: string }
146
+ throw new Error(`derive ${res.status}: ${body.error ?? res.statusText}`)
147
+ }
148
+
149
+ return {
150
+ async list(query) {
151
+ const q = query ? `?query=${encodeURIComponent(query)}` : ""
152
+ const r = (await ok(await f(`${base}/v1/artifacts${q}`, { headers: authHeaders }))) as {
153
+ artifacts: ArtifactSummaryJson[]
154
+ }
155
+ return r.artifacts
156
+ },
157
+
158
+ async publish(args) {
159
+ const bytes =
160
+ typeof args.content === "string" ? new TextEncoder().encode(args.content) : args.content
161
+ const form = new FormData()
162
+ form.append("file", new Blob([bytes as BlobPart]), args.filename)
163
+ if (args.title) form.append("title", args.title)
164
+ if (args.slug) form.append("slug", args.slug)
165
+ if (args.message) form.append("message", args.message)
166
+ if (args.visibility) form.append("visibility", args.visibility)
167
+ if (args.password) form.append("password", args.password)
168
+ if (args.spa) form.append("spa", "true")
169
+ if (args.resolves?.length) form.append("resolves", args.resolves.join(","))
170
+ const url = args.id ? `${base}/v1/artifacts/${args.id}/versions` : `${base}/v1/artifacts`
171
+ return ok(
172
+ await f(url, { method: "POST", body: form, headers: authHeaders }),
173
+ ) as Promise<ArtifactJson>
174
+ },
175
+
176
+ async propose(shortId, args) {
177
+ const form = new FormData()
178
+ form.append(
179
+ "file",
180
+ new Blob([new TextEncoder().encode(args.content)]),
181
+ args.filename ?? "index.html",
182
+ )
183
+ form.append("message", args.message)
184
+ if (args.addresses?.length) form.append("addresses", args.addresses.join(","))
185
+ return ok(
186
+ await f(`${base}/v1/artifacts/${shortId}/proposals`, {
187
+ method: "POST",
188
+ body: form,
189
+ headers: authHeaders,
190
+ }),
191
+ ) as Promise<ProposalJson>
192
+ },
193
+
194
+ async get(shortId) {
195
+ return ok(
196
+ await f(`${base}/v1/artifacts/${shortId}`, { headers: authHeaders }),
197
+ ) as Promise<ArtifactJson>
198
+ },
199
+
200
+ async getContent(shortId, version) {
201
+ const q = version ? `?v=${version}` : ""
202
+ const res = await f(`${base}/v1/artifacts/${shortId}/content${q}`, { headers: authHeaders })
203
+ if (!res.ok) {
204
+ const body = (await res.json().catch(() => ({}))) as { error?: string }
205
+ throw new Error(`derive ${res.status}: ${body.error ?? res.statusText}`)
206
+ }
207
+ return res.text()
208
+ },
209
+
210
+ async listComments(shortId, state) {
211
+ const q = state ? `?state=${state}` : ""
212
+ const r = (await ok(
213
+ await f(`${base}/v1/artifacts/${shortId}/comments${q}`, { headers: authHeaders }),
214
+ )) as { comments: CommentJson[] }
215
+ return r.comments
216
+ },
217
+
218
+ async createComment(shortId, args) {
219
+ return ok(
220
+ await f(`${base}/v1/artifacts/${shortId}/comments`, {
221
+ method: "POST",
222
+ headers: { ...authHeaders, "content-type": "application/json" },
223
+ body: JSON.stringify(args),
224
+ }),
225
+ ) as Promise<CommentJson>
226
+ },
227
+
228
+ async setThreadState(shortId, commentId, state) {
229
+ await ok(
230
+ await f(`${base}/v1/artifacts/${shortId}/comments/${commentId}/resolve`, {
231
+ method: "POST",
232
+ headers: { ...authHeaders, "content-type": "application/json" },
233
+ body: JSON.stringify({ state }),
234
+ }),
235
+ )
236
+ },
237
+
238
+ async diff(shortId, from, to) {
239
+ const q = new URLSearchParams({ format: "json" })
240
+ if (from != null) q.set("from", String(from))
241
+ if (to != null) q.set("to", String(to))
242
+ return ok(
243
+ await f(`${base}/v1/artifacts/${shortId}/diff?${q}`, { headers: authHeaders }),
244
+ ) as Promise<DiffJson>
245
+ },
246
+
247
+ async restore(shortId, version) {
248
+ return ok(
249
+ await f(`${base}/v1/artifacts/${shortId}/restore`, {
250
+ method: "POST",
251
+ headers: { ...authHeaders, "content-type": "application/json" },
252
+ body: JSON.stringify({ version }),
253
+ }),
254
+ ) as Promise<ArtifactJson>
255
+ },
256
+
257
+ async viewStats(shortId) {
258
+ return ok(
259
+ await f(`${base}/v1/artifacts/${shortId}/analytics`, { headers: authHeaders }),
260
+ ) as Promise<ViewStatsJson>
261
+ },
262
+ }
263
+ }
package/src/index.ts ADDED
@@ -0,0 +1,307 @@
1
+ import { readFileSync } from "node:fs"
2
+ import { fileURLToPath } from "node:url"
3
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"
4
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
5
+ import { z } from "zod"
6
+ import { createClient } from "./client"
7
+
8
+ // 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.)
14
+
15
+ const client = createClient({
16
+ baseUrl: process.env.DERIVE_SERVER ?? "http://localhost:8080",
17
+ token: process.env.DERIVE_TOKEN,
18
+ })
19
+
20
+ // The agent guide, served as an MCP resource (single source: SKILL.md).
21
+ const GUIDE = (() => {
22
+ try {
23
+ return readFileSync(fileURLToPath(new URL("../SKILL.md", import.meta.url)), "utf8")
24
+ } catch {
25
+ return "# Derive\nFind, read, catch_up, comment, and publish via the derive tools."
26
+ }
27
+ })()
28
+
29
+ const server = new McpServer({ name: "derive", version: "1.0.0" })
30
+
31
+ const text = (s: string) => ({ content: [{ type: "text" as const, text: s }] })
32
+ const json = (v: unknown) => text(JSON.stringify(v, null, 2))
33
+
34
+ // FIND ------------------------------------------------------------------------
35
+ server.registerTool(
36
+ "list_artifacts",
37
+ {
38
+ 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.") },
41
+ },
42
+ async ({ query }) => {
43
+ const arts = await client.list(query)
44
+ return json({ count: arts.length, artifacts: arts })
45
+ },
46
+ )
47
+
48
+ // READ CONTENT ----------------------------------------------------------------
49
+ server.registerTool(
50
+ "read",
51
+ {
52
+ 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.",
54
+ inputSchema: {
55
+ short_id: z.string(),
56
+ version: z.number().int().optional().describe("Defaults to the current version."),
57
+ },
58
+ },
59
+ async ({ short_id, version }) => {
60
+ const a = await client.get(short_id)
61
+ const body = await client.getContent(short_id, version)
62
+ const v = version ?? a.current_version
63
+ return json({ short_id, title: a.title, kind: a.kind, version: v, content: body })
64
+ },
65
+ )
66
+
67
+ // CATCH UP — state, feedback, history, and diffs all in one -------------------
68
+ server.registerTool(
69
+ "catch_up",
70
+ {
71
+ description:
72
+ "START HERE on an artifact. Its state in one call: a summary, the versions since `since_version`, the open (and outdated) comment threads, and the full version history. " +
73
+ "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.",
75
+ inputSchema: {
76
+ short_id: z.string(),
77
+ since_version: z
78
+ .number()
79
+ .int()
80
+ .optional()
81
+ .describe("The version you last saw (diff base). Defaults to to_version − 1."),
82
+ to_version: z
83
+ .number()
84
+ .int()
85
+ .optional()
86
+ .describe("Compare up to this version instead of the current one."),
87
+ comments: z
88
+ .enum(["open", "addressed", "resolved", "outdated"])
89
+ .optional()
90
+ .describe(
91
+ "Return ONLY this state's comment threads (the feedback queue) instead of the delta.",
92
+ ),
93
+ response_format: z
94
+ .enum(["summary", "detailed"])
95
+ .optional()
96
+ .describe("'summary' (default) omits the line diff; 'detailed' includes it."),
97
+ },
98
+ },
99
+ async ({ short_id, since_version, to_version, comments, response_format }) => {
100
+ const summarizeComment = (c: {
101
+ thread_id: string
102
+ author: string
103
+ state: string
104
+ anchor: string | null
105
+ body_md: string
106
+ }) => ({
107
+ thread: c.thread_id,
108
+ author: c.author,
109
+ state: c.state,
110
+ quote: c.anchor,
111
+ body: c.body_md,
112
+ })
113
+
114
+ if (comments) {
115
+ const list = await client.listComments(short_id, comments)
116
+ return json({
117
+ short_id,
118
+ comments_state: comments,
119
+ count: list.length,
120
+ comments: list.map(summarizeComment),
121
+ })
122
+ }
123
+
124
+ const a = await client.get(short_id)
125
+ const head = a.current_version
126
+ const to = Math.min(head, Math.max(1, to_version ?? head))
127
+ const since = Math.min(to, Math.max(1, since_version ?? to - 1))
128
+ const history = a.versions.slice().sort((x, y) => y.n - x.n)
129
+ const newVersions = history.filter((v) => v.n > since && v.n <= to)
130
+ const [open, outdated, addressed] = await Promise.all([
131
+ client.listComments(short_id, "open"),
132
+ client.listComments(short_id, "outdated"),
133
+ client.listComments(short_id, "addressed"),
134
+ ])
135
+ let entryDiff: string | undefined
136
+ if (response_format === "detailed" && since < to) {
137
+ const d = await client.diff(short_id, since, to)
138
+ entryDiff = d.ops
139
+ .map((o) => `${o.t === "add" ? "+" : o.t === "del" ? "-" : " "} ${o.line}`)
140
+ .join("\n")
141
+ }
142
+ const outdatedBit = outdated.length ? ` ${outdated.length} now outdated.` : ""
143
+ const addressedBit = addressed.length ? ` ${addressed.length} addressed (pending review).` : ""
144
+ const summary =
145
+ since >= to
146
+ ? `You're up to date on "${a.title}" (v${head}); ${open.length} open comment(s).${addressedBit}${outdatedBit}`
147
+ : `"${a.title}": ${newVersions.length} new version(s) since v${since} (now v${to}). ${open.length} open comment(s).${addressedBit}${outdatedBit}`
148
+ return json({
149
+ summary,
150
+ short_id,
151
+ since,
152
+ to,
153
+ head,
154
+ caught_up: since >= to,
155
+ versions: history,
156
+ new_versions: newVersions,
157
+ ...(entryDiff
158
+ ? { entry_diff: entryDiff }
159
+ : {
160
+ entry_diff: "(omitted) — call again with response_format='detailed' for the line diff.",
161
+ }),
162
+ open_comments: open.map(summarizeComment),
163
+ ...(outdated.length ? { outdated_comments: outdated.map(summarizeComment) } : {}),
164
+ })
165
+ },
166
+ )
167
+
168
+ // COMMENT — leave / reply / resolve feedback ----------------------------------
169
+ server.registerTool(
170
+ "comment",
171
+ {
172
+ description:
173
+ "Leave feedback, reply in a thread, and/or resolve or reopen a thread. Anchor a NEW comment to a quoted span with `quote`. Reply by passing the thread id as `reply_to`. Resolve/reopen by passing `set_state` with a `comment_id` from the thread (or the comment you just left).",
174
+ inputSchema: {
175
+ short_id: z.string(),
176
+ body: z
177
+ .string()
178
+ .optional()
179
+ .describe("The comment text. Omit when only changing thread state."),
180
+ reply_to: z
181
+ .string()
182
+ .optional()
183
+ .describe("A thread id to reply in; omit to start a new thread."),
184
+ quote: z.string().optional().describe("Exact text to anchor a NEW comment to."),
185
+ set_state: z.enum(["resolved", "open"]).optional().describe("Resolve or reopen a thread."),
186
+ comment_id: z
187
+ .string()
188
+ .optional()
189
+ .describe("A comment in the thread to set_state on (when not posting)."),
190
+ },
191
+ },
192
+ async ({ short_id, body, reply_to, quote, set_state, comment_id }) => {
193
+ if (!body && !set_state)
194
+ return text("Provide `body` (to comment) or `set_state` (to resolve/reopen).")
195
+ let posted: Awaited<ReturnType<typeof client.createComment>> | undefined
196
+ if (body) {
197
+ const anchor = quote ? { type: "TextQuoteSelector", exact: quote } : undefined
198
+ posted = await client.createComment(short_id, {
199
+ thread_id: reply_to,
200
+ body_md: body,
201
+ anchor,
202
+ author: "agent",
203
+ })
204
+ }
205
+ let stateNote = ""
206
+ if (set_state) {
207
+ const ref = posted?.id ?? comment_id
208
+ if (!ref)
209
+ return text(
210
+ "`set_state` needs a `comment_id` (a comment in the thread) or a `body` to post and resolve.",
211
+ )
212
+ await client.setThreadState(short_id, ref, set_state)
213
+ stateNote = ` · thread ${set_state === "resolved" ? "resolved" : "reopened"}`
214
+ }
215
+ if (posted) {
216
+ const where = reply_to
217
+ ? `replied in thread ${posted.thread_id}`
218
+ : `new thread ${posted.thread_id}`
219
+ return text(`${where} (comment ${posted.id})${quote ? ` on “${quote}”` : ""}${stateNote}.`)
220
+ }
221
+ return text(`Thread ${set_state === "resolved" ? "resolved" : "reopened"}.`)
222
+ },
223
+ )
224
+
225
+ // WRITE — publish live, or file a proposal for review -------------------------
226
+ server.registerTool(
227
+ "publish",
228
+ {
229
+ description:
230
+ "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.)",
231
+ inputSchema: {
232
+ content: z.string().describe("The artifact's text content (HTML or Markdown)."),
233
+ filename: z
234
+ .string()
235
+ .optional()
236
+ .describe("Filename, e.g. report.html or notes.md. Defaults to index.html."),
237
+ short_id: z
238
+ .string()
239
+ .optional()
240
+ .describe("Omit to create a new artifact; pass it to add a version."),
241
+ title: z.string().optional(),
242
+ // `password` stays CLI/web-only (it needs a password argument this tool
243
+ // doesn't take). Omitted ⇒ the server default, `private` (the publish is
244
+ // owned by the user the agent acts on behalf of).
245
+ visibility: z.enum(["public", "link", "org", "private"]).optional(),
246
+ message: z.string().optional().describe("What changed in this version."),
247
+ for_review: z
248
+ .boolean()
249
+ .optional()
250
+ .describe("File as a proposal for human review instead of publishing live."),
251
+ addresses: z
252
+ .array(z.string())
253
+ .optional()
254
+ .describe("Thread ids this revision resolves (live publish) or addresses (proposal)."),
255
+ },
256
+ },
257
+ async ({ content, filename, short_id, title, visibility, message, for_review, addresses }) => {
258
+ if (for_review) {
259
+ if (!short_id) return text("A proposal revises an EXISTING artifact — pass its short_id.")
260
+ const p = await client.propose(short_id, {
261
+ content,
262
+ filename,
263
+ message: message ?? "Proposed revision",
264
+ addresses,
265
+ })
266
+ const note = p.addressed?.length ? ` · addressed ${p.addressed.length} thread(s)` : ""
267
+ return json({
268
+ proposed: true,
269
+ proposal_id: p.id,
270
+ base_version: p.base_version,
271
+ note: `Submitted for review (not live)${note}.`,
272
+ })
273
+ }
274
+ const a = await client.publish({
275
+ id: short_id,
276
+ content,
277
+ filename: filename ?? "index.html",
278
+ title,
279
+ visibility,
280
+ message,
281
+ resolves: addresses,
282
+ })
283
+ const note = addresses?.length ? ` · resolved ${addresses.length} thread(s)` : ""
284
+ return json({
285
+ published: true,
286
+ short_id: a.short_id,
287
+ version: a.current_version,
288
+ url: a.url,
289
+ title: a.title,
290
+ note: short_id ? `Live — new version${note}.` : `Live — created "${a.title}"${note}.`,
291
+ })
292
+ },
293
+ )
294
+
295
+ // Expose the agent loop as an MCP resource so any client can read the conventions.
296
+ server.registerResource(
297
+ "derive-guide",
298
+ "derive://guide",
299
+ {
300
+ title: "Derive agent guide",
301
+ description: "How to run the publish → review → revise loop.",
302
+ mimeType: "text/markdown",
303
+ },
304
+ async (uri) => ({ contents: [{ uri: uri.href, mimeType: "text/markdown", text: GUIDE }] }),
305
+ )
306
+
307
+ await server.connect(new StdioServerTransport())