@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 +105 -0
- package/SKILL.md +65 -0
- package/bin/derive-mcp.mjs +8 -0
- package/package.json +60 -0
- package/src/client.ts +263 -0
- package/src/index.ts +307 -0
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())
|