@derive-to/mcp 0.2.0 → 0.4.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/LICENSE +105 -0
- package/SKILL.md +65 -6
- package/package.json +14 -13
- package/src/client.ts +139 -27
- package/src/index.ts +355 -49
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
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.
|
|
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 the
|
|
25
|
+
| `read` | Read an artifact's content by short id, as **Markdown by default** (HTML is converted — headings, lists, tables, code fences; the styling noise is dropped). Omit `section` and a small doc/bundle returns whole; a **large one returns its outline first** (heading slugs for a single-file doc, page paths for a bundle) — call again with a `section` (a slug, a bundle page, `page.html#slug`, or `"*"` for the full clipped document). Pass `format:'html'` for the exact stored source (needed before publish `edits`) or `format:'text'` for flat visible text (what comment `quote`s anchor against). Pass `version` to read history. An image page in a bundle comes back as a real image, not garbage text. |
|
|
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. 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. |
|
|
29
29
|
|
|
30
30
|
## Role decides: live publish vs proposal
|
|
31
31
|
|
|
@@ -54,6 +54,54 @@ human approves rather than live content.
|
|
|
54
54
|
`addresses` + `request_review:true` for the next round. The human never
|
|
55
55
|
resolves threads — you settle thread state.
|
|
56
56
|
|
|
57
|
+
## Reading big documents
|
|
58
|
+
|
|
59
|
+
`read` never hands you a wall of JSON-escaped HTML. A content-bearing response is
|
|
60
|
+
a small frontmatter header (short id, title, version, format, section, size, url)
|
|
61
|
+
followed by a blank line and the raw body — real newlines, greppable if a client
|
|
62
|
+
spills it to a file. When a document is large, `read` (no `section`) returns its
|
|
63
|
+
heading outline instead of the full text:
|
|
64
|
+
|
|
65
|
+
```
|
|
66
|
+
{ "sections": [
|
|
67
|
+
{ "slug": "why-one-engine", "level": 2, "text": "Why: one engine", "chars": 2210 },
|
|
68
|
+
{ "slug": "pr-6-the-fix", "level": 2, "text": "PR-6: the fix", "chars": 4812 }
|
|
69
|
+
], "next": "Call read again with a section slug…" }
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Pull just the part you need: `read(short_id, { section: "pr-6-the-fix" })`. Pass
|
|
73
|
+
`section: "*"` to force the full (clipped) document when you genuinely need it all.
|
|
74
|
+
|
|
75
|
+
## Edit, don't resend
|
|
76
|
+
|
|
77
|
+
Once you've read a section, revise it with `publish`'s `edits` instead of
|
|
78
|
+
resending the whole artifact:
|
|
79
|
+
|
|
80
|
+
```
|
|
81
|
+
publish(short_id, { edits: [{ old_str: "exact text from the source", new_str: "replacement" }] })
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Each `old_str` must match **exactly once** in the current stored source — the
|
|
85
|
+
same contract as a coding Edit tool. If it doesn't match (or matches more than
|
|
86
|
+
once), nothing is applied and the error names which edit failed, so you add more
|
|
87
|
+
surrounding context and retry. For an HTML artifact, read with `format:'html'`
|
|
88
|
+
first — the Markdown view won't match raw source. Pass `base_version` (the
|
|
89
|
+
version you read) to fail fast instead of silently editing a version you never saw.
|
|
90
|
+
|
|
91
|
+
## Mockups & screens
|
|
92
|
+
|
|
93
|
+
Reading Markdown by default doesn't flatten design work:
|
|
94
|
+
|
|
95
|
+
- **See it rendered**: every `read` response's frontmatter carries the artifact's
|
|
96
|
+
`url` — open it in a real browser (or a browser-automation tool) to view or
|
|
97
|
+
screenshot the live page.
|
|
98
|
+
- **See its structure/copy**: the default Markdown read.
|
|
99
|
+
- **See a screenshot inline**: reading an image page of a bundle (`section:
|
|
100
|
+
"shot.png"`) returns a real image content block, not decoded bytes as text.
|
|
101
|
+
- **Edit it**: `read(section, format:'html')` for the exact markup, then
|
|
102
|
+
`publish({ edits })` for a surgical change — a label, a color token — without
|
|
103
|
+
resending the whole design.
|
|
104
|
+
|
|
57
105
|
## Keep comments anchorable
|
|
58
106
|
|
|
59
107
|
Anchors are text quotes with surrounding context, matched in the rendered
|
|
@@ -66,7 +114,18 @@ wrong place.
|
|
|
66
114
|
|
|
67
115
|
- Versions are immutable; `@vN` URLs never change. The viewer groups rapid
|
|
68
116
|
same-author revisions into time-based sessions, but every revision is addressable.
|
|
69
|
-
- Multi-page bundles are readable (`read` with a `section
|
|
117
|
+
- Multi-page bundles are readable on both servers (`read` with a `section` — a page
|
|
118
|
+
path, or `page.html#slug` for one heading's part; `catch_up`) but revisable only
|
|
70
119
|
over the remote `/mcp` server via `publish` with a `files` map. Over the stdio
|
|
71
|
-
`@derive-to/mcp` server, bundles are publish-via-remote/web only, and `comment`
|
|
72
|
-
takes a `comment_id`.
|
|
120
|
+
`@derive-to/mcp` server, bundles are publish-via-remote/web only, and `comment`
|
|
121
|
+
set_state takes a `comment_id`. `edits` (single-file only) works on both. Both
|
|
122
|
+
servers expose the same 5 tools.
|
|
123
|
+
- An older self-hosted Derive server that predates `format`/`section`/`outline`
|
|
124
|
+
responds to `read` with a note that it returned the full raw artifact instead —
|
|
125
|
+
the stdio client detects this from a missing response header and degrades rather
|
|
126
|
+
than silently misreading a section.
|
|
127
|
+
- The stdio server shares the machine's `derive login` — no token to paste. It acts as
|
|
128
|
+
your stored default account/workspace unless `DERIVE_ACCOUNT`/`DERIVE_WORKSPACE`
|
|
129
|
+
pin the project to a specific one (id or name; set in `.mcp.json`'s `env`). Still no
|
|
130
|
+
`whoami` tool, but a wrong pin fails loudly at startup rather than silently
|
|
131
|
+
targeting the wrong workspace.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@derive-to/mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.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": [
|
|
@@ -37,24 +37,25 @@
|
|
|
37
37
|
".": "./src/index.ts",
|
|
38
38
|
"./client": "./src/client.ts"
|
|
39
39
|
},
|
|
40
|
-
"scripts": {
|
|
41
|
-
"start": "tsx src/index.ts",
|
|
42
|
-
"typecheck": "tsc --noEmit",
|
|
43
|
-
"test": "vitest run",
|
|
44
|
-
"test:coverage": "vitest run --coverage"
|
|
45
|
-
},
|
|
46
40
|
"dependencies": {
|
|
47
41
|
"@modelcontextprotocol/sdk": "^1.12.0",
|
|
48
42
|
"tsx": "^4.19.0",
|
|
49
|
-
"zod": "^4.4.3"
|
|
43
|
+
"zod": "^4.4.3",
|
|
44
|
+
"@derive-to/cli": "0.3.0"
|
|
50
45
|
},
|
|
51
46
|
"devDependencies": {
|
|
52
|
-
"@derive/api": "workspace:*",
|
|
53
|
-
"@derive/db": "workspace:*",
|
|
54
|
-
"@derive/storage": "workspace:*",
|
|
55
47
|
"@hono/node-server": "^2.0.5",
|
|
56
48
|
"@types/node": "^25.9.3",
|
|
57
49
|
"typescript": "^6.0.3",
|
|
58
|
-
"vitest": "^4.1.9"
|
|
50
|
+
"vitest": "^4.1.9",
|
|
51
|
+
"@derive/db": "0.1.0",
|
|
52
|
+
"@derive/storage": "0.1.0",
|
|
53
|
+
"@derive/api": "0.1.0"
|
|
54
|
+
},
|
|
55
|
+
"scripts": {
|
|
56
|
+
"start": "tsx src/index.ts",
|
|
57
|
+
"typecheck": "tsc --noEmit",
|
|
58
|
+
"test": "vitest run",
|
|
59
|
+
"test:coverage": "vitest run --coverage"
|
|
59
60
|
}
|
|
60
|
-
}
|
|
61
|
+
}
|
package/src/client.ts
CHANGED
|
@@ -1,14 +1,25 @@
|
|
|
1
1
|
/** HTTP client for a Derive server. Shared by the MCP server and any tooling. */
|
|
2
2
|
|
|
3
|
+
/** One exact-match search/replace edit (the Edit-tool contract). */
|
|
4
|
+
export interface DocEdit {
|
|
5
|
+
old_str: string
|
|
6
|
+
new_str: string
|
|
7
|
+
}
|
|
8
|
+
|
|
3
9
|
export interface PublishArgs {
|
|
4
|
-
content
|
|
5
|
-
|
|
10
|
+
/** Full content for a fresh publish/republish. Omit when using `edits` instead. */
|
|
11
|
+
content?: string | Uint8Array
|
|
12
|
+
filename?: string
|
|
6
13
|
title?: string
|
|
7
14
|
slug?: string
|
|
8
15
|
spa?: boolean
|
|
9
16
|
message?: string
|
|
10
|
-
|
|
11
|
-
|
|
17
|
+
/** The v2 access triple for a NEW artifact (see access-model.md); ignored on a
|
|
18
|
+
* republish. */
|
|
19
|
+
workspaceAccess?: "none" | "member"
|
|
20
|
+
linkRole?: "none" | "viewer" | "commenter" | "editor"
|
|
21
|
+
listed?: "none" | "workspace" | "public"
|
|
22
|
+
/** A lock on the world link (optional). */
|
|
12
23
|
password?: string
|
|
13
24
|
/** When set, publishes a new version of this artifact instead of a new one. */
|
|
14
25
|
id?: string
|
|
@@ -16,6 +27,11 @@ export interface PublishArgs {
|
|
|
16
27
|
resolves?: string[]
|
|
17
28
|
/** Open a review round for this version (the /derive loop's ask). */
|
|
18
29
|
requestReview?: boolean
|
|
30
|
+
/** Exact-match search/replace against the current stored source, INSTEAD of
|
|
31
|
+
* `content` — revises without resending the whole artifact. Requires `id`. */
|
|
32
|
+
edits?: DocEdit[]
|
|
33
|
+
/** Safety check for `edits`: reject if the artifact moved past this version. */
|
|
34
|
+
baseVersion?: number
|
|
19
35
|
}
|
|
20
36
|
|
|
21
37
|
export type CommentState = "open" | "addressed" | "resolved" | "outdated"
|
|
@@ -39,16 +55,23 @@ export interface ArtifactSummaryJson {
|
|
|
39
55
|
title: string | null
|
|
40
56
|
kind: "file" | "bundle"
|
|
41
57
|
current_version: number
|
|
42
|
-
|
|
58
|
+
workspace_access?: string
|
|
59
|
+
link_role?: string
|
|
60
|
+
listed?: string
|
|
43
61
|
}
|
|
44
62
|
|
|
45
63
|
/** A revision submitted for human review instead of published live. */
|
|
46
64
|
export interface ProposeArgs {
|
|
47
|
-
content
|
|
65
|
+
/** Full content for the proposal. Omit when using `edits` instead. */
|
|
66
|
+
content?: string
|
|
48
67
|
filename?: string
|
|
49
68
|
message: string
|
|
50
69
|
/** Thread ids this revision addresses (flip to `addressed`, resolve on approval). */
|
|
51
70
|
addresses?: string[]
|
|
71
|
+
/** Exact-match search/replace against the current stored source, INSTEAD of
|
|
72
|
+
* `content`. */
|
|
73
|
+
edits?: DocEdit[]
|
|
74
|
+
baseVersion?: number
|
|
52
75
|
}
|
|
53
76
|
export interface ProposalJson {
|
|
54
77
|
id: string
|
|
@@ -85,7 +108,11 @@ export interface ArtifactJson {
|
|
|
85
108
|
url: string
|
|
86
109
|
title: string | null
|
|
87
110
|
kind: "file" | "bundle"
|
|
88
|
-
|
|
111
|
+
/** The v2 access triple (see access-model.md); optional so an older server that
|
|
112
|
+
* still returns `visibility` doesn't fail the type. */
|
|
113
|
+
workspace_access?: string
|
|
114
|
+
link_role?: string
|
|
115
|
+
listed?: string
|
|
89
116
|
current_version: number
|
|
90
117
|
versions: VersionJson[]
|
|
91
118
|
/** Time-grouped version view (newest-first); present on the detail endpoint. */
|
|
@@ -123,6 +150,32 @@ export interface ViewStatsJson {
|
|
|
123
150
|
recent: { viewer: string; kind: "user" | "anon"; at: string }[]
|
|
124
151
|
}
|
|
125
152
|
|
|
153
|
+
export interface ContentOpts {
|
|
154
|
+
version?: number
|
|
155
|
+
/** A heading slug (single-file) or page path (bundle, optionally page#slug). */
|
|
156
|
+
section?: string
|
|
157
|
+
format?: "markdown" | "text"
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** A content read: the body plus the server's X-Derive-* capability headers, so a
|
|
161
|
+
* caller can tell an older self-hosted server (no headers at all) from a real
|
|
162
|
+
* raw-format response and degrade gracefully instead of misreading intent. */
|
|
163
|
+
export interface ContentResult {
|
|
164
|
+
text: string
|
|
165
|
+
/** Null when the server predates these params (no X-Derive-Format header). */
|
|
166
|
+
format: string | null
|
|
167
|
+
section: string | null
|
|
168
|
+
sectionCount: number | null
|
|
169
|
+
supportsParams: boolean
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export interface OutlineSectionJson {
|
|
173
|
+
level: number
|
|
174
|
+
text: string
|
|
175
|
+
slug: string
|
|
176
|
+
chars: number
|
|
177
|
+
}
|
|
178
|
+
|
|
126
179
|
export interface DeriveClient {
|
|
127
180
|
/** List the workspace's artifacts (optionally filtered by a title query). */
|
|
128
181
|
list(query?: string): Promise<ArtifactSummaryJson[]>
|
|
@@ -130,13 +183,20 @@ export interface DeriveClient {
|
|
|
130
183
|
/** Submit a single-file revision for human review (does not go live). */
|
|
131
184
|
propose(shortId: string, args: ProposeArgs): Promise<ProposalJson>
|
|
132
185
|
get(shortId: string): Promise<ArtifactJson>
|
|
133
|
-
getContent(shortId: string,
|
|
186
|
+
getContent(shortId: string, opts?: ContentOpts): Promise<ContentResult>
|
|
187
|
+
/** The heading (single-file) or page (bundle) outline. Empty `sections` on an
|
|
188
|
+
* older server that doesn't understand `?outline=1` (it 400s or ignores it). */
|
|
189
|
+
getOutline(
|
|
190
|
+
shortId: string,
|
|
191
|
+
version?: number,
|
|
192
|
+
): Promise<{ sections: OutlineSectionJson[]; pages: { path: string; type?: string }[] | null }>
|
|
134
193
|
listComments(shortId: string, state?: CommentState): Promise<CommentJson[]>
|
|
135
194
|
createComment(shortId: string, args: NewCommentArgs): Promise<CommentJson>
|
|
136
195
|
/** Resolve or reopen the thread a comment belongs to. */
|
|
137
196
|
setThreadState(shortId: string, commentId: string, state: "resolved" | "open"): Promise<void>
|
|
138
|
-
/** Line diff between two versions (defaults: current-1 → current).
|
|
139
|
-
|
|
197
|
+
/** Line diff between two versions (defaults: current-1 → current). `content:
|
|
198
|
+
* "markdown"` diffs the readable Markdown form instead of raw source. */
|
|
199
|
+
diff(shortId: string, from?: number, to?: number, content?: "raw" | "markdown"): Promise<DiffJson>
|
|
140
200
|
/** The artifact's review rounds (newest first) + the pending one, if any. */
|
|
141
201
|
getReview(
|
|
142
202
|
shortId: string,
|
|
@@ -152,6 +212,11 @@ export interface DeriveClient {
|
|
|
152
212
|
export interface ClientOptions {
|
|
153
213
|
baseUrl: string
|
|
154
214
|
token?: string
|
|
215
|
+
/** Which workspace `token` acts in for this request — the token itself already
|
|
216
|
+
* reaches every workspace its owner belongs to; this just tells the server
|
|
217
|
+
* which one. Omit to fall back to the grant's own default (unchanged
|
|
218
|
+
* behavior for a plain static DERIVE_TOKEN). */
|
|
219
|
+
workspace?: string
|
|
155
220
|
/** Override fetch (used in tests to target an in-process server). */
|
|
156
221
|
fetchImpl?: typeof fetch
|
|
157
222
|
}
|
|
@@ -159,9 +224,10 @@ export interface ClientOptions {
|
|
|
159
224
|
export function createClient(opts: ClientOptions): DeriveClient {
|
|
160
225
|
const base = opts.baseUrl.replace(/\/$/, "")
|
|
161
226
|
const f = opts.fetchImpl ?? fetch
|
|
162
|
-
const authHeaders: Record<string, string> =
|
|
163
|
-
? { Authorization: `Bearer ${opts.token}` }
|
|
164
|
-
: {}
|
|
227
|
+
const authHeaders: Record<string, string> = {
|
|
228
|
+
...(opts.token ? { Authorization: `Bearer ${opts.token}` } : {}),
|
|
229
|
+
...(opts.workspace ? { "X-Derive-Workspace": opts.workspace } : {}),
|
|
230
|
+
}
|
|
165
231
|
|
|
166
232
|
async function ok(res: Response): Promise<unknown> {
|
|
167
233
|
if (res.ok) return res.json()
|
|
@@ -179,14 +245,24 @@ export function createClient(opts: ClientOptions): DeriveClient {
|
|
|
179
245
|
},
|
|
180
246
|
|
|
181
247
|
async publish(args) {
|
|
182
|
-
const bytes =
|
|
183
|
-
typeof args.content === "string" ? new TextEncoder().encode(args.content) : args.content
|
|
184
248
|
const form = new FormData()
|
|
185
|
-
|
|
249
|
+
if (args.edits) {
|
|
250
|
+
// Surgical revision: no file upload, the server materializes it from the
|
|
251
|
+
// current stored source. Requires an existing artifact (args.id).
|
|
252
|
+
form.append("edits", JSON.stringify(args.edits))
|
|
253
|
+
if (args.baseVersion != null) form.append("base_version", String(args.baseVersion))
|
|
254
|
+
if (args.filename) form.append("filename", args.filename)
|
|
255
|
+
} else {
|
|
256
|
+
const bytes =
|
|
257
|
+
typeof args.content === "string" ? new TextEncoder().encode(args.content) : args.content
|
|
258
|
+
form.append("file", new Blob([bytes as BlobPart]), args.filename ?? "index.html")
|
|
259
|
+
}
|
|
186
260
|
if (args.title) form.append("title", args.title)
|
|
187
261
|
if (args.slug) form.append("slug", args.slug)
|
|
188
262
|
if (args.message) form.append("message", args.message)
|
|
189
|
-
if (args.
|
|
263
|
+
if (args.workspaceAccess) form.append("workspace_access", args.workspaceAccess)
|
|
264
|
+
if (args.linkRole) form.append("link_role", args.linkRole)
|
|
265
|
+
if (args.listed) form.append("listed", args.listed)
|
|
190
266
|
if (args.password) form.append("password", args.password)
|
|
191
267
|
if (args.spa) form.append("spa", "true")
|
|
192
268
|
if (args.resolves?.length) form.append("resolves", args.resolves.join(","))
|
|
@@ -199,11 +275,16 @@ export function createClient(opts: ClientOptions): DeriveClient {
|
|
|
199
275
|
|
|
200
276
|
async propose(shortId, args) {
|
|
201
277
|
const form = new FormData()
|
|
202
|
-
|
|
203
|
-
"
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
278
|
+
if (args.edits) {
|
|
279
|
+
form.append("edits", JSON.stringify(args.edits))
|
|
280
|
+
if (args.baseVersion != null) form.append("base_version", String(args.baseVersion))
|
|
281
|
+
} else {
|
|
282
|
+
form.append(
|
|
283
|
+
"file",
|
|
284
|
+
new Blob([new TextEncoder().encode(args.content ?? "")]),
|
|
285
|
+
args.filename ?? "index.html",
|
|
286
|
+
)
|
|
287
|
+
}
|
|
207
288
|
form.append("message", args.message)
|
|
208
289
|
if (args.addresses?.length) form.append("addresses", args.addresses.join(","))
|
|
209
290
|
return ok(
|
|
@@ -221,14 +302,44 @@ export function createClient(opts: ClientOptions): DeriveClient {
|
|
|
221
302
|
) as Promise<ArtifactJson>
|
|
222
303
|
},
|
|
223
304
|
|
|
224
|
-
async getContent(shortId,
|
|
225
|
-
const q =
|
|
226
|
-
|
|
305
|
+
async getContent(shortId, opts) {
|
|
306
|
+
const q = new URLSearchParams()
|
|
307
|
+
if (opts?.version) q.set("v", String(opts.version))
|
|
308
|
+
if (opts?.section) q.set("section", opts.section)
|
|
309
|
+
if (opts?.format) q.set("format", opts.format)
|
|
310
|
+
const qs = q.toString()
|
|
311
|
+
const res = await f(`${base}/v1/artifacts/${shortId}/content${qs ? `?${qs}` : ""}`, {
|
|
312
|
+
headers: authHeaders,
|
|
313
|
+
})
|
|
227
314
|
if (!res.ok) {
|
|
228
315
|
const body = (await res.json().catch(() => ({}))) as { error?: string }
|
|
229
316
|
throw new Error(`derive ${res.status}: ${body.error ?? res.statusText}`)
|
|
230
317
|
}
|
|
231
|
-
|
|
318
|
+
const format = res.headers.get("x-derive-format")
|
|
319
|
+
return {
|
|
320
|
+
text: await res.text(),
|
|
321
|
+
format,
|
|
322
|
+
section: res.headers.get("x-derive-section"),
|
|
323
|
+
sectionCount: res.headers.has("x-derive-sections")
|
|
324
|
+
? Number(res.headers.get("x-derive-sections"))
|
|
325
|
+
: null,
|
|
326
|
+
// No X-Derive-Format header at all = a server that predates these params
|
|
327
|
+
// (an older self-hosted instance) — the caller should treat this as raw
|
|
328
|
+
// whole-artifact content and not assume format/section were honored.
|
|
329
|
+
supportsParams: format !== null,
|
|
330
|
+
}
|
|
331
|
+
},
|
|
332
|
+
|
|
333
|
+
async getOutline(shortId, version) {
|
|
334
|
+
const q = new URLSearchParams({ outline: "1" })
|
|
335
|
+
if (version) q.set("v", String(version))
|
|
336
|
+
const res = await f(`${base}/v1/artifacts/${shortId}/content?${q}`, { headers: authHeaders })
|
|
337
|
+
if (!res.ok) return { sections: [], pages: null }
|
|
338
|
+
const body = (await res.json()) as {
|
|
339
|
+
sections?: OutlineSectionJson[]
|
|
340
|
+
pages?: { path: string; type?: string }[]
|
|
341
|
+
}
|
|
342
|
+
return { sections: body.sections ?? [], pages: body.pages ?? null }
|
|
232
343
|
},
|
|
233
344
|
|
|
234
345
|
async listComments(shortId, state) {
|
|
@@ -259,10 +370,11 @@ export function createClient(opts: ClientOptions): DeriveClient {
|
|
|
259
370
|
)
|
|
260
371
|
},
|
|
261
372
|
|
|
262
|
-
async diff(shortId, from, to) {
|
|
373
|
+
async diff(shortId, from, to, content) {
|
|
263
374
|
const q = new URLSearchParams({ format: "json" })
|
|
264
375
|
if (from != null) q.set("from", String(from))
|
|
265
376
|
if (to != null) q.set("to", String(to))
|
|
377
|
+
if (content === "markdown") q.set("content", "markdown")
|
|
266
378
|
return ok(
|
|
267
379
|
await f(`${base}/v1/artifacts/${shortId}/diff?${q}`, { headers: authHeaders }),
|
|
268
380
|
) as Promise<DiffJson>
|
package/src/index.ts
CHANGED
|
@@ -1,21 +1,96 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs"
|
|
2
2
|
import { fileURLToPath } from "node:url"
|
|
3
|
+
import {
|
|
4
|
+
findAccountWorkspace,
|
|
5
|
+
freshToken,
|
|
6
|
+
getAccount,
|
|
7
|
+
getDefault,
|
|
8
|
+
listAccounts,
|
|
9
|
+
resolveAccountRef,
|
|
10
|
+
resolveWorkspaceRef,
|
|
11
|
+
} from "@derive-to/cli/config"
|
|
3
12
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"
|
|
4
13
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
|
|
5
14
|
import { z } from "zod"
|
|
6
15
|
import { createClient } from "./client"
|
|
7
16
|
|
|
8
17
|
// Stdio MCP server for self-hosters: `npx @derive-to/mcp` talks to a Derive instance over
|
|
9
|
-
// the /v1 HTTP API (DERIVE_SERVER)
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
18
|
+
// the /v1 HTTP API (DERIVE_SERVER). It exposes the SAME tools as the remote /mcp
|
|
19
|
+
// server — list_workspaces, list_artifacts, read, catch_up, comment, publish — so the
|
|
20
|
+
// vocabulary is identical whether an agent connects over OAuth or a static token.
|
|
21
|
+
//
|
|
22
|
+
// No token to paste: by default this reads the SAME local store `derive login`
|
|
23
|
+
// writes (~/.config/derive/credentials.json), refreshing silently — sign in once
|
|
24
|
+
// on the machine and every project's MCP server just works. DERIVE_ACCOUNT /
|
|
25
|
+
// DERIVE_WORKSPACE pin which signed-in account/workspace THIS project acts as by
|
|
26
|
+
// DEFAULT (id or name); unset, it falls back to your stored default. Because one
|
|
27
|
+
// login reaches every workspace the account belongs to, any tool also takes a
|
|
28
|
+
// per-call `workspace` argument (see list_workspaces) to act in another one without
|
|
29
|
+
// changing that pin. DERIVE_TOKEN remains an escape hatch for a static bearer (CI,
|
|
30
|
+
// no local login) — DERIVE_WORKSPACE has no effect there, since a static token
|
|
31
|
+
// already acts as every workspace's owner.
|
|
32
|
+
const server_ = process.env.DERIVE_SERVER ?? "http://localhost:8080"
|
|
14
33
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
34
|
+
/** {token, workspace} for the client below — see the module doc comment for the
|
|
35
|
+
* precedence. Kept out of `createClient` itself so a resolution failure fails
|
|
36
|
+
* loudly at startup (a clear thrown message) instead of quietly targeting the
|
|
37
|
+
* wrong workspace, or surfacing as an unexplained 401 mid-session. Not signed
|
|
38
|
+
* in at all (no env override, nothing saved) degrades gracefully to anonymous,
|
|
39
|
+
* same as today's unset DERIVE_TOKEN — only an env var naming something that
|
|
40
|
+
* doesn't exist is an error. */
|
|
41
|
+
async function resolveAuth(): Promise<{
|
|
42
|
+
token?: string
|
|
43
|
+
workspace?: string
|
|
44
|
+
accountId?: string
|
|
45
|
+
}> {
|
|
46
|
+
if (process.env.DERIVE_TOKEN) return { token: process.env.DERIVE_TOKEN }
|
|
47
|
+
|
|
48
|
+
const accountEnv = process.env.DERIVE_ACCOUNT
|
|
49
|
+
const workspaceEnv = process.env.DERIVE_WORKSPACE
|
|
50
|
+
let accountId: string | null
|
|
51
|
+
let workspace: string | undefined
|
|
52
|
+
|
|
53
|
+
if (accountEnv) {
|
|
54
|
+
accountId = resolveAccountRef(server_, accountEnv)
|
|
55
|
+
if (!accountId)
|
|
56
|
+
throw new Error(
|
|
57
|
+
`DERIVE_ACCOUNT "${accountEnv}" isn't signed in on this machine — run \`derive login\`.`,
|
|
58
|
+
)
|
|
59
|
+
if (workspaceEnv) {
|
|
60
|
+
const found = findAccountWorkspace(server_, accountId, workspaceEnv)
|
|
61
|
+
if (!found)
|
|
62
|
+
throw new Error(
|
|
63
|
+
`DERIVE_WORKSPACE "${workspaceEnv}" isn't one of that account's workspaces — run \`derive workspaces --account ${accountEnv}\`.`,
|
|
64
|
+
)
|
|
65
|
+
workspace = found.id
|
|
66
|
+
} else {
|
|
67
|
+
workspace = getAccount(server_, accountId)?.defaultWorkspace ?? undefined
|
|
68
|
+
}
|
|
69
|
+
} else if (workspaceEnv) {
|
|
70
|
+
const resolved = resolveWorkspaceRef(server_, workspaceEnv)
|
|
71
|
+
if (!resolved)
|
|
72
|
+
throw new Error(
|
|
73
|
+
`DERIVE_WORKSPACE "${workspaceEnv}" isn't a workspace on any signed-in account.`,
|
|
74
|
+
)
|
|
75
|
+
if ("ambiguous" in resolved)
|
|
76
|
+
throw new Error(
|
|
77
|
+
`DERIVE_WORKSPACE "${workspaceEnv}" matches workspaces under more than one account — set DERIVE_ACCOUNT too.`,
|
|
78
|
+
)
|
|
79
|
+
accountId = resolved.accountId
|
|
80
|
+
workspace = resolved.workspaceId
|
|
81
|
+
} else {
|
|
82
|
+
const def = getDefault(server_)
|
|
83
|
+
accountId = def?.account ?? null
|
|
84
|
+
workspace = def?.workspace ?? undefined
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (!accountId) return {}
|
|
88
|
+
const token = (await freshToken(server_, accountId)) ?? undefined
|
|
89
|
+
return { token, workspace, accountId }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const { token, workspace, accountId } = await resolveAuth()
|
|
93
|
+
const client = createClient({ baseUrl: server_, token, workspace })
|
|
19
94
|
|
|
20
95
|
// The agent guide, served as an MCP resource (single source: SKILL.md).
|
|
21
96
|
const GUIDE = (() => {
|
|
@@ -30,17 +105,94 @@ const server = new McpServer({ name: "derive", version: "1.0.0" })
|
|
|
30
105
|
|
|
31
106
|
const text = (s: string) => ({ content: [{ type: "text" as const, text: s }] })
|
|
32
107
|
const json = (v: unknown) => text(JSON.stringify(v, null, 2))
|
|
108
|
+
const err = (s: string) => ({
|
|
109
|
+
content: [{ type: "text" as const, text: s }],
|
|
110
|
+
isError: true as const,
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
// A content-bearing response: a frontmatter-style header, a blank line, then the
|
|
114
|
+
// RAW body — never JSON-escaped (parity with the remote server's envelope).
|
|
115
|
+
const doc = (meta: Record<string, string | number | null | undefined>, body: string) => {
|
|
116
|
+
const head = Object.entries(meta)
|
|
117
|
+
.filter(([, v]) => v !== undefined && v !== null)
|
|
118
|
+
.map(([k, v]) => `${k}: ${v}`)
|
|
119
|
+
.join("\n")
|
|
120
|
+
return text(`---\n${head}\n---\n\n${body}`)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// A `workspace` arg (id or name) on any tool acts in THAT workspace for the one
|
|
124
|
+
// call, without re-pinning the session: the token already reaches every workspace
|
|
125
|
+
// the account belongs to, so we just build a throwaway client that sends the
|
|
126
|
+
// matching X-Derive-Workspace header. Names resolve against the active account's
|
|
127
|
+
// local roster; an unrecognized ref is passed through as a literal id (a static
|
|
128
|
+
// DERIVE_TOKEN caller has no roster to match names against). Omit → the session's
|
|
129
|
+
// resolved default client.
|
|
130
|
+
const resolveWsId = (ref: string): string => {
|
|
131
|
+
if (accountId) {
|
|
132
|
+
const found = findAccountWorkspace(server_, accountId, ref)
|
|
133
|
+
if (found) return found.id
|
|
134
|
+
}
|
|
135
|
+
return ref
|
|
136
|
+
}
|
|
137
|
+
const clientFor = (ref?: string) =>
|
|
138
|
+
ref ? createClient({ baseUrl: server_, token, workspace: resolveWsId(ref) }) : client
|
|
139
|
+
const wsArg = z
|
|
140
|
+
.string()
|
|
141
|
+
.optional()
|
|
142
|
+
.describe(
|
|
143
|
+
"Workspace to act in — its id or name from list_workspaces. Omit to use this session's default workspace.",
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
// The signed-in roster on THIS machine (shared by the list_workspaces tool and the
|
|
147
|
+
// derive://workspaces resource) — read fresh so a sibling `derive login`/`describe`
|
|
148
|
+
// shows up mid-session.
|
|
149
|
+
const buildRoster = () => {
|
|
150
|
+
const accounts = listAccounts(server_).map((a) => {
|
|
151
|
+
const account = getAccount(server_, a.id)
|
|
152
|
+
return {
|
|
153
|
+
account_id: a.id,
|
|
154
|
+
handle: a.handle,
|
|
155
|
+
is_default_account: a.isDefault,
|
|
156
|
+
workspaces: Object.entries(account?.workspaces ?? {}).map(([id, w]) => ({
|
|
157
|
+
workspace_id: id,
|
|
158
|
+
name: w.name,
|
|
159
|
+
role: w.role,
|
|
160
|
+
description: w.description ?? null,
|
|
161
|
+
is_default_workspace: id === account?.defaultWorkspace,
|
|
162
|
+
is_active: id === workspace,
|
|
163
|
+
})),
|
|
164
|
+
}
|
|
165
|
+
})
|
|
166
|
+
const active = accountId
|
|
167
|
+
? { server: server_, account_id: accountId, workspace_id: workspace ?? null }
|
|
168
|
+
: { server: server_, note: "No signed-in account resolved for this session." }
|
|
169
|
+
return { active, accounts }
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// WORKSPACES — the switcher: every workspace signed in on this machine ---------
|
|
173
|
+
server.registerTool(
|
|
174
|
+
"list_workspaces",
|
|
175
|
+
{
|
|
176
|
+
description:
|
|
177
|
+
"List every workspace signed in on this machine you can act in — id, name, your role, local description, and which is active. One login reaches them all; pass a workspace's id or name as the `workspace` argument to list_artifacts / read / catch_up / comment / publish to act there for that call.",
|
|
178
|
+
inputSchema: {},
|
|
179
|
+
},
|
|
180
|
+
async () => json(buildRoster()),
|
|
181
|
+
)
|
|
33
182
|
|
|
34
183
|
// FIND ------------------------------------------------------------------------
|
|
35
184
|
server.registerTool(
|
|
36
185
|
"list_artifacts",
|
|
37
186
|
{
|
|
38
187
|
description:
|
|
39
|
-
"List the artifacts in your workspace — short id, title, kind, current version,
|
|
40
|
-
inputSchema: {
|
|
188
|
+
"List the artifacts in your workspace — short id, title, kind, current version, access. Defaults to this session's workspace; pass `workspace` (id or name from list_workspaces) to list another. Start here to find what to work on, then catch_up or read it.",
|
|
189
|
+
inputSchema: {
|
|
190
|
+
query: z.string().optional().describe("Optional title search filter."),
|
|
191
|
+
workspace: wsArg,
|
|
192
|
+
},
|
|
41
193
|
},
|
|
42
|
-
async ({ query }) => {
|
|
43
|
-
const arts = await
|
|
194
|
+
async ({ query, workspace: ws }) => {
|
|
195
|
+
const arts = await clientFor(ws).list(query)
|
|
44
196
|
return json({ count: arts.length, artifacts: arts })
|
|
45
197
|
},
|
|
46
198
|
)
|
|
@@ -50,17 +202,74 @@ server.registerTool(
|
|
|
50
202
|
"read",
|
|
51
203
|
{
|
|
52
204
|
description:
|
|
53
|
-
"Read an artifact's CONTENT by short id (a
|
|
205
|
+
"Read an artifact's CONTENT by short id, as Markdown by default (HTML is converted). Omit `section` to see the outline first (heading slugs for a single-file doc, page paths for a bundle) — call again with a `section` (or \"*\" for the full document) once you know what you want. Pass `format:'html'` for the exact source (needed before publish `edits`), or a past `version` for history. For what CHANGED or the comment threads, use catch_up instead. (Older self-hosted servers that predate these params return the whole artifact regardless of section/format — noted in the response when that happens.)",
|
|
54
206
|
inputSchema: {
|
|
55
207
|
short_id: z.string(),
|
|
208
|
+
section: z
|
|
209
|
+
.string()
|
|
210
|
+
.optional()
|
|
211
|
+
.describe(
|
|
212
|
+
'A heading slug (single-file) or page path (bundle, optionally page#slug). Pass "*" for the full document.',
|
|
213
|
+
),
|
|
214
|
+
format: z
|
|
215
|
+
.enum(["markdown", "text"])
|
|
216
|
+
.optional()
|
|
217
|
+
.describe("markdown (default, HTML converted) or text (flat visible text)."),
|
|
56
218
|
version: z.number().int().optional().describe("Defaults to the current version."),
|
|
219
|
+
workspace: wsArg,
|
|
57
220
|
},
|
|
58
221
|
},
|
|
59
|
-
async ({ short_id, version }) => {
|
|
222
|
+
async ({ short_id, section, format, version, workspace: ws }) => {
|
|
223
|
+
const client = clientFor(ws)
|
|
60
224
|
const a = await client.get(short_id)
|
|
61
|
-
const body = await client.getContent(short_id, version)
|
|
62
225
|
const v = version ?? a.current_version
|
|
63
|
-
|
|
226
|
+
|
|
227
|
+
// No section: show the outline first (mirrors the remote server's
|
|
228
|
+
// outline-before-blind-dump behavior). Falls back to full content when the
|
|
229
|
+
// artifact has no headings/pages, or the server predates `?outline=1`.
|
|
230
|
+
if (!section) {
|
|
231
|
+
const outline = await client.getOutline(short_id, version)
|
|
232
|
+
if (outline.sections.length || outline.pages) {
|
|
233
|
+
return json({
|
|
234
|
+
short_id,
|
|
235
|
+
title: a.title,
|
|
236
|
+
kind: a.kind,
|
|
237
|
+
version: v,
|
|
238
|
+
...(outline.sections.length ? { sections: outline.sections } : {}),
|
|
239
|
+
...(outline.pages ? { pages: outline.pages } : {}),
|
|
240
|
+
next:
|
|
241
|
+
outline.sections.length || outline.pages?.length
|
|
242
|
+
? 'Call read again with a `section` (a slug/page above), or section:"*" for the full document.'
|
|
243
|
+
: undefined,
|
|
244
|
+
})
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
try {
|
|
249
|
+
const result = await client.getContent(short_id, {
|
|
250
|
+
version,
|
|
251
|
+
section,
|
|
252
|
+
format: format ?? "markdown",
|
|
253
|
+
})
|
|
254
|
+
if (!result.supportsParams)
|
|
255
|
+
return doc(
|
|
256
|
+
{ short_id, title: a.title, kind: a.kind, version: v },
|
|
257
|
+
`${result.text}\n\n[note: this server predates section/format params — returning the full raw artifact.]`,
|
|
258
|
+
)
|
|
259
|
+
return doc(
|
|
260
|
+
{
|
|
261
|
+
short_id,
|
|
262
|
+
title: a.title,
|
|
263
|
+
kind: a.kind,
|
|
264
|
+
version: v,
|
|
265
|
+
...(result.format ? { format: result.format } : {}),
|
|
266
|
+
...(result.section ? { section: result.section } : {}),
|
|
267
|
+
},
|
|
268
|
+
result.text,
|
|
269
|
+
)
|
|
270
|
+
} catch (e) {
|
|
271
|
+
return err(e instanceof Error ? e.message : "read failed")
|
|
272
|
+
}
|
|
64
273
|
},
|
|
65
274
|
)
|
|
66
275
|
|
|
@@ -71,7 +280,7 @@ server.registerTool(
|
|
|
71
280
|
description:
|
|
72
281
|
"START HERE on an artifact. Its state in one call: a summary, the review round, the versions since `since_version`, the open (and outdated) comment threads, and the full version history. " +
|
|
73
282
|
"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
|
|
283
|
+
"Pass `response_format='detailed'` (optionally with `since_version`/`to_version`) to fold in a line diff between two versions — of their readable Markdown form, not raw HTML. " +
|
|
75
284
|
"WAITING ON A REVIEW? Pass `wait` (seconds, max 50) to block until the human sends back or approves — chain these instead of sleeping between polls.",
|
|
76
285
|
inputSchema: {
|
|
77
286
|
short_id: z.string(),
|
|
@@ -104,9 +313,19 @@ server.registerTool(
|
|
|
104
313
|
.describe(
|
|
105
314
|
"Long-poll: block up to this many seconds for the human's next review action before returning. Returns immediately when something is already actionable.",
|
|
106
315
|
),
|
|
316
|
+
workspace: wsArg,
|
|
107
317
|
},
|
|
108
318
|
},
|
|
109
|
-
async ({
|
|
319
|
+
async ({
|
|
320
|
+
short_id,
|
|
321
|
+
since_version,
|
|
322
|
+
to_version,
|
|
323
|
+
comments,
|
|
324
|
+
response_format,
|
|
325
|
+
wait,
|
|
326
|
+
workspace: ws,
|
|
327
|
+
}) => {
|
|
328
|
+
const client = clientFor(ws)
|
|
110
329
|
const summarizeComment = (c: {
|
|
111
330
|
thread_id: string
|
|
112
331
|
author: string
|
|
@@ -198,7 +417,9 @@ server.registerTool(
|
|
|
198
417
|
: ""
|
|
199
418
|
let entryDiff: string | undefined
|
|
200
419
|
if (response_format === "detailed" && since < to) {
|
|
201
|
-
|
|
420
|
+
// Diff the readable Markdown form, not raw HTML — kills tag noise and
|
|
421
|
+
// avoids a minified one-line document producing one useless del/add pair.
|
|
422
|
+
const d = await client.diff(short_id, since, to, "markdown")
|
|
202
423
|
entryDiff = d.ops
|
|
203
424
|
.map((o) => `${o.t === "add" ? "+" : o.t === "del" ? "-" : " "} ${o.line}`)
|
|
204
425
|
.join("\n")
|
|
@@ -256,9 +477,11 @@ server.registerTool(
|
|
|
256
477
|
.string()
|
|
257
478
|
.optional()
|
|
258
479
|
.describe("A comment in the thread to react to / set_state on (when not posting)."),
|
|
480
|
+
workspace: wsArg,
|
|
259
481
|
},
|
|
260
482
|
},
|
|
261
|
-
async ({ short_id, body, reply_to, quote, react, set_state, comment_id }) => {
|
|
483
|
+
async ({ short_id, body, reply_to, quote, react, set_state, comment_id, workspace: ws }) => {
|
|
484
|
+
const client = clientFor(ws)
|
|
262
485
|
if (!body && !set_state && !react)
|
|
263
486
|
return text(
|
|
264
487
|
"Provide `body` (to comment), `react` (to acknowledge), or `set_state` (to resolve/reopen).",
|
|
@@ -327,9 +550,33 @@ server.registerTool(
|
|
|
327
550
|
"publish",
|
|
328
551
|
{
|
|
329
552
|
description:
|
|
330
|
-
"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.)",
|
|
553
|
+
"Publish a single-file artifact and get a permanent URL. OMIT short_id to create a NEW artifact (title recommended); PASS short_id to publish a new version (same URL). To CHANGE PART of an existing artifact, prefer `edits` (exact-match search/replace against the stored source — read format:'html' first) over resending everything via `content`. Pass for_review:true to file it as a PROPOSAL a human approves instead of going live. Pass `addresses` with the thread ids this revision resolves. (Multi-page bundles are published via the web app or the remote /mcp server.)",
|
|
331
554
|
inputSchema: {
|
|
332
|
-
content: z
|
|
555
|
+
content: z
|
|
556
|
+
.string()
|
|
557
|
+
.optional()
|
|
558
|
+
.describe("The artifact's full text content (HTML or Markdown). Use this OR `edits`."),
|
|
559
|
+
edits: z
|
|
560
|
+
.array(
|
|
561
|
+
z.object({
|
|
562
|
+
old_str: z
|
|
563
|
+
.string()
|
|
564
|
+
.describe(
|
|
565
|
+
"Exact text from the STORED SOURCE (read format:'html' first on an HTML artifact). Must occur exactly once.",
|
|
566
|
+
),
|
|
567
|
+
new_str: z.string().describe("Replacement text. Empty string deletes."),
|
|
568
|
+
}),
|
|
569
|
+
)
|
|
570
|
+
.optional()
|
|
571
|
+
.describe(
|
|
572
|
+
"Surgical revision without resending the artifact: exact-match search/replace against the current stored source, applied in order. Errors (applying nothing) if any old_str matches zero or multiple times. Requires `short_id`; use INSTEAD of `content`.",
|
|
573
|
+
),
|
|
574
|
+
base_version: z
|
|
575
|
+
.number()
|
|
576
|
+
.optional()
|
|
577
|
+
.describe(
|
|
578
|
+
"Safety check for `edits`: pass the version you read; errors instead of applying if the artifact moved past it.",
|
|
579
|
+
),
|
|
333
580
|
filename: z
|
|
334
581
|
.string()
|
|
335
582
|
.optional()
|
|
@@ -339,9 +586,12 @@ server.registerTool(
|
|
|
339
586
|
.optional()
|
|
340
587
|
.describe("Omit to create a new artifact; pass it to add a version."),
|
|
341
588
|
title: z.string().optional(),
|
|
342
|
-
//
|
|
343
|
-
//
|
|
344
|
-
|
|
589
|
+
// The v2 access triple for a NEW artifact (see access-model.md); omit any to
|
|
590
|
+
// take the workspace default (the team draft — the human you act for owns it
|
|
591
|
+
// and promotes it when ready). Ignored on a republish.
|
|
592
|
+
workspace_access: z.enum(["none", "member"]).optional(),
|
|
593
|
+
link_role: z.enum(["none", "viewer", "commenter", "editor"]).optional(),
|
|
594
|
+
listed: z.enum(["none", "workspace", "public"]).optional(),
|
|
345
595
|
message: z.string().optional().describe("What changed in this version."),
|
|
346
596
|
for_review: z
|
|
347
597
|
.boolean()
|
|
@@ -357,45 +607,69 @@ server.registerTool(
|
|
|
357
607
|
.describe(
|
|
358
608
|
"Open a review round asking your human to review this version — the /derive loop. Poll catch_up's `review` (or pass `wait`) for the state.",
|
|
359
609
|
),
|
|
610
|
+
workspace: wsArg,
|
|
360
611
|
},
|
|
361
612
|
},
|
|
362
613
|
async ({
|
|
363
614
|
content,
|
|
615
|
+
edits,
|
|
616
|
+
base_version,
|
|
364
617
|
filename,
|
|
365
618
|
short_id,
|
|
366
619
|
title,
|
|
367
|
-
|
|
620
|
+
workspace_access,
|
|
621
|
+
link_role,
|
|
622
|
+
listed,
|
|
368
623
|
message,
|
|
369
624
|
for_review,
|
|
370
625
|
addresses,
|
|
371
626
|
request_review,
|
|
627
|
+
workspace: ws,
|
|
372
628
|
}) => {
|
|
629
|
+
const client = clientFor(ws)
|
|
630
|
+
if (content !== undefined && edits) return text("Provide `content` OR `edits`, not both.")
|
|
373
631
|
if (for_review) {
|
|
374
632
|
if (!short_id) return text("A proposal revises an EXISTING artifact — pass its short_id.")
|
|
375
|
-
|
|
633
|
+
try {
|
|
634
|
+
const p = await client.propose(short_id, {
|
|
635
|
+
content,
|
|
636
|
+
edits,
|
|
637
|
+
baseVersion: base_version,
|
|
638
|
+
filename,
|
|
639
|
+
message: message ?? "Proposed revision",
|
|
640
|
+
addresses,
|
|
641
|
+
})
|
|
642
|
+
const note = p.addressed?.length ? ` · addressed ${p.addressed.length} thread(s)` : ""
|
|
643
|
+
return json({
|
|
644
|
+
proposed: true,
|
|
645
|
+
proposal_id: p.id,
|
|
646
|
+
base_version: p.base_version,
|
|
647
|
+
note: `Submitted for review (not live)${note}.`,
|
|
648
|
+
})
|
|
649
|
+
} catch (e) {
|
|
650
|
+
return err(e instanceof Error ? e.message : "propose failed")
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
if (edits && !short_id) return text("`edits` revises an EXISTING artifact — pass its short_id.")
|
|
654
|
+
let a: Awaited<ReturnType<typeof client.publish>>
|
|
655
|
+
try {
|
|
656
|
+
a = await client.publish({
|
|
657
|
+
id: short_id,
|
|
376
658
|
content,
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
659
|
+
edits,
|
|
660
|
+
baseVersion: base_version,
|
|
661
|
+
filename: filename ?? (edits ? undefined : "index.html"),
|
|
662
|
+
title,
|
|
663
|
+
workspaceAccess: workspace_access,
|
|
664
|
+
linkRole: link_role,
|
|
665
|
+
listed,
|
|
666
|
+
message,
|
|
667
|
+
resolves: addresses,
|
|
668
|
+
requestReview: request_review,
|
|
387
669
|
})
|
|
670
|
+
} catch (e) {
|
|
671
|
+
return err(e instanceof Error ? e.message : "publish failed")
|
|
388
672
|
}
|
|
389
|
-
const a = await client.publish({
|
|
390
|
-
id: short_id,
|
|
391
|
-
content,
|
|
392
|
-
filename: filename ?? "index.html",
|
|
393
|
-
title,
|
|
394
|
-
visibility,
|
|
395
|
-
message,
|
|
396
|
-
resolves: addresses,
|
|
397
|
-
requestReview: request_review,
|
|
398
|
-
})
|
|
399
673
|
const note = addresses?.length ? ` · resolved ${addresses.length} thread(s)` : ""
|
|
400
674
|
const openNote =
|
|
401
675
|
a.opened_in_tab === false
|
|
@@ -408,7 +682,8 @@ server.registerTool(
|
|
|
408
682
|
version: a.current_version,
|
|
409
683
|
url: a.url,
|
|
410
684
|
title: a.title,
|
|
411
|
-
|
|
685
|
+
listed: a.listed,
|
|
686
|
+
link_role: a.link_role,
|
|
412
687
|
...(a.opened_in_tab !== undefined ? { opened_in_tab: a.opened_in_tab } : {}),
|
|
413
688
|
note:
|
|
414
689
|
(short_id ? `Live — new version${note}.` : `Live — created "${a.title}"${note}.`) +
|
|
@@ -429,4 +704,35 @@ server.registerResource(
|
|
|
429
704
|
async (uri) => ({ contents: [{ uri: uri.href, mimeType: "text/markdown", text: GUIDE }] }),
|
|
430
705
|
)
|
|
431
706
|
|
|
707
|
+
// Every account/workspace signed in on THIS machine, with the local `description`
|
|
708
|
+
// each was given via `derive workspace describe` — the context a bare name can't
|
|
709
|
+
// carry. This tool's OWN live calls only ever act as `active` below (fixed at
|
|
710
|
+
// startup by DERIVE_ACCOUNT/DERIVE_WORKSPACE or the stored default); the rest of
|
|
711
|
+
// the roster is visibility only, for deciding whether that pin is still the right
|
|
712
|
+
// one — e.g. before proposing a change to a project's .mcp.json. Read fresh (not
|
|
713
|
+
// cached at startup) since `derive workspace describe` can run in a sibling
|
|
714
|
+
// terminal mid-session.
|
|
715
|
+
server.registerResource(
|
|
716
|
+
"derive-workspaces",
|
|
717
|
+
"derive://workspaces",
|
|
718
|
+
{
|
|
719
|
+
title: "Signed-in accounts & workspaces",
|
|
720
|
+
description:
|
|
721
|
+
"Every account and workspace signed in on this machine, each with its local `description` " +
|
|
722
|
+
"(what it's FOR, set via `derive workspace describe`) if one has been set. `active` is the " +
|
|
723
|
+
"one this session's tools actually publish to — read this before assuming a bare workspace " +
|
|
724
|
+
"name is enough context, or before touching a project's DERIVE_ACCOUNT/DERIVE_WORKSPACE pin.",
|
|
725
|
+
mimeType: "application/json",
|
|
726
|
+
},
|
|
727
|
+
async (uri) => ({
|
|
728
|
+
contents: [
|
|
729
|
+
{
|
|
730
|
+
uri: uri.href,
|
|
731
|
+
mimeType: "application/json",
|
|
732
|
+
text: JSON.stringify(buildRoster(), null, 2),
|
|
733
|
+
},
|
|
734
|
+
],
|
|
735
|
+
}),
|
|
736
|
+
)
|
|
737
|
+
|
|
432
738
|
await server.connect(new StdioServerTransport())
|