@derive-to/mcp 0.1.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/SKILL.md +73 -7
- package/package.json +13 -12
- package/src/client.ts +179 -27
- package/src/index.ts +490 -59
- package/LICENSE +0 -105
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, 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
|
|
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. |
|
|
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
|
+
| `comment` | Leave feedback, reply (`reply_to` a thread id), anchor to a `quote`, react (`react: "👍"` with `reply_to` — the loop's lightweight ack, landing on the thread's latest human comment), and/or resolve/reopen (`set_state`). |
|
|
28
|
+
| `publish` | Save a revision. `content` for a single file, `files` (path→content map) for a multi-page bundle, or **`edits`** (`[{old_str, new_str}]`) to revise part of a single-file artifact without resending it. Omit `short_id` to create new (title required); pass it to add a version. `addresses` lists thread ids this revision resolves; `request_review:true` opens a review round for your human. New artifacts land **private** by default (the human you act for owns the draft) — they promote via the share dialog, so don't pass a wider `visibility` unasked. The result's `opened_in_tab` says whether an open Derive tab caught the push; when false, open the `url` for the user if they should see it now. |
|
|
29
29
|
|
|
30
30
|
## Role decides: live publish vs proposal
|
|
31
31
|
|
|
@@ -46,6 +46,61 @@ human approves rather than live content.
|
|
|
46
46
|
3. **Revise**, then **`comment`** (reply/resolve) and/or **`publish`** (pass `addresses`
|
|
47
47
|
to resolve the threads this revision fixes) — same URL, a new version. Comment
|
|
48
48
|
highlights re-anchor to the moved text.
|
|
49
|
+
4. **Review rounds** (the /derive loop): publish with `request_review:true`, then
|
|
50
|
+
chain `catch_up(short_id, wait: 50)` — each call returns the moment the human
|
|
51
|
+
hits Send back / Approve (or ~50s pass). On `sent_back`, sweep ALL threads (any
|
|
52
|
+
author, anchored or not), ack every human comment FIRST
|
|
53
|
+
(`comment(reply_to, react:"👍")` at minimum), then revise and publish with
|
|
54
|
+
`addresses` + `request_review:true` for the next round. The human never
|
|
55
|
+
resolves threads — you settle thread state.
|
|
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.
|
|
49
104
|
|
|
50
105
|
## Keep comments anchorable
|
|
51
106
|
|
|
@@ -59,7 +114,18 @@ wrong place.
|
|
|
59
114
|
|
|
60
115
|
- Versions are immutable; `@vN` URLs never change. The viewer groups rapid
|
|
61
116
|
same-author revisions into time-based sessions, but every revision is addressable.
|
|
62
|
-
- 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
|
|
63
119
|
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`
|
|
65
|
-
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.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Stdio MCP server for Derive — list, read, catch up on, comment on, and publish artifacts on a Derive instance.",
|
|
6
6
|
"keywords": [
|
|
@@ -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
|
+
},
|
|
40
46
|
"dependencies": {
|
|
47
|
+
"@derive-to/cli": "workspace:*",
|
|
41
48
|
"@modelcontextprotocol/sdk": "^1.12.0",
|
|
42
49
|
"tsx": "^4.19.0",
|
|
43
50
|
"zod": "^4.4.3"
|
|
44
51
|
},
|
|
45
52
|
"devDependencies": {
|
|
53
|
+
"@derive/api": "workspace:*",
|
|
54
|
+
"@derive/db": "workspace:*",
|
|
55
|
+
"@derive/storage": "workspace:*",
|
|
46
56
|
"@hono/node-server": "^2.0.5",
|
|
47
57
|
"@types/node": "^25.9.3",
|
|
48
58
|
"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
|
+
"vitest": "^4.1.9"
|
|
59
60
|
}
|
|
60
|
-
}
|
|
61
|
+
}
|
package/src/client.ts
CHANGED
|
@@ -1,19 +1,37 @@
|
|
|
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
|
|
15
26
|
/** Comment ids whose threads to resolve as part of this (re)publish. */
|
|
16
27
|
resolves?: string[]
|
|
28
|
+
/** Open a review round for this version (the /derive loop's ask). */
|
|
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
|
|
17
35
|
}
|
|
18
36
|
|
|
19
37
|
export type CommentState = "open" | "addressed" | "resolved" | "outdated"
|
|
@@ -28,6 +46,8 @@ export interface CommentJson {
|
|
|
28
46
|
author: string
|
|
29
47
|
state: CommentState
|
|
30
48
|
created_at: string
|
|
49
|
+
/** emoji → actor display names (the ack surface). */
|
|
50
|
+
reactions?: Record<string, string[]>
|
|
31
51
|
}
|
|
32
52
|
|
|
33
53
|
export interface ArtifactSummaryJson {
|
|
@@ -35,16 +55,23 @@ export interface ArtifactSummaryJson {
|
|
|
35
55
|
title: string | null
|
|
36
56
|
kind: "file" | "bundle"
|
|
37
57
|
current_version: number
|
|
38
|
-
|
|
58
|
+
workspace_access?: string
|
|
59
|
+
link_role?: string
|
|
60
|
+
listed?: string
|
|
39
61
|
}
|
|
40
62
|
|
|
41
63
|
/** A revision submitted for human review instead of published live. */
|
|
42
64
|
export interface ProposeArgs {
|
|
43
|
-
content
|
|
65
|
+
/** Full content for the proposal. Omit when using `edits` instead. */
|
|
66
|
+
content?: string
|
|
44
67
|
filename?: string
|
|
45
68
|
message: string
|
|
46
69
|
/** Thread ids this revision addresses (flip to `addressed`, resolve on approval). */
|
|
47
70
|
addresses?: string[]
|
|
71
|
+
/** Exact-match search/replace against the current stored source, INSTEAD of
|
|
72
|
+
* `content`. */
|
|
73
|
+
edits?: DocEdit[]
|
|
74
|
+
baseVersion?: number
|
|
48
75
|
}
|
|
49
76
|
export interface ProposalJson {
|
|
50
77
|
id: string
|
|
@@ -81,11 +108,28 @@ export interface ArtifactJson {
|
|
|
81
108
|
url: string
|
|
82
109
|
title: string | null
|
|
83
110
|
kind: "file" | "bundle"
|
|
84
|
-
|
|
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
|
|
85
116
|
current_version: number
|
|
86
117
|
versions: VersionJson[]
|
|
87
118
|
/** Time-grouped version view (newest-first); present on the detail endpoint. */
|
|
88
119
|
sessions?: SessionJson[]
|
|
120
|
+
/** Publish-response extras (agent-credentialed publishes only). */
|
|
121
|
+
review_requested?: boolean
|
|
122
|
+
opened_in_tab?: boolean
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** One review round: the human-ack primitive of the /derive loop. */
|
|
126
|
+
export interface ReviewRoundJson {
|
|
127
|
+
id: string
|
|
128
|
+
state: "pending" | "sent_back" | "approved"
|
|
129
|
+
version: number
|
|
130
|
+
note: string | null
|
|
131
|
+
created_at: string
|
|
132
|
+
resolved_at: string | null
|
|
89
133
|
}
|
|
90
134
|
|
|
91
135
|
export interface DiffOpJson {
|
|
@@ -106,6 +150,32 @@ export interface ViewStatsJson {
|
|
|
106
150
|
recent: { viewer: string; kind: "user" | "anon"; at: string }[]
|
|
107
151
|
}
|
|
108
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
|
+
|
|
109
179
|
export interface DeriveClient {
|
|
110
180
|
/** List the workspace's artifacts (optionally filtered by a title query). */
|
|
111
181
|
list(query?: string): Promise<ArtifactSummaryJson[]>
|
|
@@ -113,13 +183,26 @@ export interface DeriveClient {
|
|
|
113
183
|
/** Submit a single-file revision for human review (does not go live). */
|
|
114
184
|
propose(shortId: string, args: ProposeArgs): Promise<ProposalJson>
|
|
115
185
|
get(shortId: string): Promise<ArtifactJson>
|
|
116
|
-
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 }>
|
|
117
193
|
listComments(shortId: string, state?: CommentState): Promise<CommentJson[]>
|
|
118
194
|
createComment(shortId: string, args: NewCommentArgs): Promise<CommentJson>
|
|
119
195
|
/** Resolve or reopen the thread a comment belongs to. */
|
|
120
196
|
setThreadState(shortId: string, commentId: string, state: "resolved" | "open"): Promise<void>
|
|
121
|
-
/** Line diff between two versions (defaults: current-1 → current).
|
|
122
|
-
|
|
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>
|
|
200
|
+
/** The artifact's review rounds (newest first) + the pending one, if any. */
|
|
201
|
+
getReview(
|
|
202
|
+
shortId: string,
|
|
203
|
+
): Promise<{ rounds: ReviewRoundJson[]; pending: ReviewRoundJson | null }>
|
|
204
|
+
/** Toggle an emoji reaction on a comment (the loop's lightweight ack). */
|
|
205
|
+
react(shortId: string, commentId: string, emoji: string): Promise<void>
|
|
123
206
|
/** Restore a past version as a new current revision. */
|
|
124
207
|
restore(shortId: string, version: number): Promise<ArtifactJson>
|
|
125
208
|
/** Aggregated view analytics. */
|
|
@@ -129,6 +212,11 @@ export interface DeriveClient {
|
|
|
129
212
|
export interface ClientOptions {
|
|
130
213
|
baseUrl: string
|
|
131
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
|
|
132
220
|
/** Override fetch (used in tests to target an in-process server). */
|
|
133
221
|
fetchImpl?: typeof fetch
|
|
134
222
|
}
|
|
@@ -136,9 +224,10 @@ export interface ClientOptions {
|
|
|
136
224
|
export function createClient(opts: ClientOptions): DeriveClient {
|
|
137
225
|
const base = opts.baseUrl.replace(/\/$/, "")
|
|
138
226
|
const f = opts.fetchImpl ?? fetch
|
|
139
|
-
const authHeaders: Record<string, string> =
|
|
140
|
-
? { Authorization: `Bearer ${opts.token}` }
|
|
141
|
-
: {}
|
|
227
|
+
const authHeaders: Record<string, string> = {
|
|
228
|
+
...(opts.token ? { Authorization: `Bearer ${opts.token}` } : {}),
|
|
229
|
+
...(opts.workspace ? { "X-Derive-Workspace": opts.workspace } : {}),
|
|
230
|
+
}
|
|
142
231
|
|
|
143
232
|
async function ok(res: Response): Promise<unknown> {
|
|
144
233
|
if (res.ok) return res.json()
|
|
@@ -156,17 +245,28 @@ export function createClient(opts: ClientOptions): DeriveClient {
|
|
|
156
245
|
},
|
|
157
246
|
|
|
158
247
|
async publish(args) {
|
|
159
|
-
const bytes =
|
|
160
|
-
typeof args.content === "string" ? new TextEncoder().encode(args.content) : args.content
|
|
161
248
|
const form = new FormData()
|
|
162
|
-
|
|
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
|
+
}
|
|
163
260
|
if (args.title) form.append("title", args.title)
|
|
164
261
|
if (args.slug) form.append("slug", args.slug)
|
|
165
262
|
if (args.message) form.append("message", args.message)
|
|
166
|
-
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)
|
|
167
266
|
if (args.password) form.append("password", args.password)
|
|
168
267
|
if (args.spa) form.append("spa", "true")
|
|
169
268
|
if (args.resolves?.length) form.append("resolves", args.resolves.join(","))
|
|
269
|
+
if (args.requestReview) form.append("request_review", "true")
|
|
170
270
|
const url = args.id ? `${base}/v1/artifacts/${args.id}/versions` : `${base}/v1/artifacts`
|
|
171
271
|
return ok(
|
|
172
272
|
await f(url, { method: "POST", body: form, headers: authHeaders }),
|
|
@@ -175,11 +275,16 @@ export function createClient(opts: ClientOptions): DeriveClient {
|
|
|
175
275
|
|
|
176
276
|
async propose(shortId, args) {
|
|
177
277
|
const form = new FormData()
|
|
178
|
-
|
|
179
|
-
"
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
+
}
|
|
183
288
|
form.append("message", args.message)
|
|
184
289
|
if (args.addresses?.length) form.append("addresses", args.addresses.join(","))
|
|
185
290
|
return ok(
|
|
@@ -197,14 +302,44 @@ export function createClient(opts: ClientOptions): DeriveClient {
|
|
|
197
302
|
) as Promise<ArtifactJson>
|
|
198
303
|
},
|
|
199
304
|
|
|
200
|
-
async getContent(shortId,
|
|
201
|
-
const q =
|
|
202
|
-
|
|
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
|
+
})
|
|
203
314
|
if (!res.ok) {
|
|
204
315
|
const body = (await res.json().catch(() => ({}))) as { error?: string }
|
|
205
316
|
throw new Error(`derive ${res.status}: ${body.error ?? res.statusText}`)
|
|
206
317
|
}
|
|
207
|
-
|
|
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 }
|
|
208
343
|
},
|
|
209
344
|
|
|
210
345
|
async listComments(shortId, state) {
|
|
@@ -235,15 +370,32 @@ export function createClient(opts: ClientOptions): DeriveClient {
|
|
|
235
370
|
)
|
|
236
371
|
},
|
|
237
372
|
|
|
238
|
-
async diff(shortId, from, to) {
|
|
373
|
+
async diff(shortId, from, to, content) {
|
|
239
374
|
const q = new URLSearchParams({ format: "json" })
|
|
240
375
|
if (from != null) q.set("from", String(from))
|
|
241
376
|
if (to != null) q.set("to", String(to))
|
|
377
|
+
if (content === "markdown") q.set("content", "markdown")
|
|
242
378
|
return ok(
|
|
243
379
|
await f(`${base}/v1/artifacts/${shortId}/diff?${q}`, { headers: authHeaders }),
|
|
244
380
|
) as Promise<DiffJson>
|
|
245
381
|
},
|
|
246
382
|
|
|
383
|
+
async getReview(shortId) {
|
|
384
|
+
return ok(
|
|
385
|
+
await f(`${base}/v1/artifacts/${shortId}/review`, { headers: authHeaders }),
|
|
386
|
+
) as Promise<{ rounds: ReviewRoundJson[]; pending: ReviewRoundJson | null }>
|
|
387
|
+
},
|
|
388
|
+
|
|
389
|
+
async react(shortId, commentId, emoji) {
|
|
390
|
+
await ok(
|
|
391
|
+
await f(`${base}/v1/artifacts/${shortId}/comments/${commentId}/react`, {
|
|
392
|
+
method: "POST",
|
|
393
|
+
headers: { ...authHeaders, "content-type": "application/json" },
|
|
394
|
+
body: JSON.stringify({ emoji }),
|
|
395
|
+
}),
|
|
396
|
+
)
|
|
397
|
+
},
|
|
398
|
+
|
|
247
399
|
async restore(shortId, version) {
|
|
248
400
|
return ok(
|
|
249
401
|
await f(`${base}/v1/artifacts/${shortId}/restore`, {
|
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
|
|
|
@@ -69,9 +278,10 @@ server.registerTool(
|
|
|
69
278
|
"catch_up",
|
|
70
279
|
{
|
|
71
280
|
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. " +
|
|
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. " +
|
|
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.",
|
|
75
285
|
inputSchema: {
|
|
76
286
|
short_id: z.string(),
|
|
77
287
|
since_version: z
|
|
@@ -94,9 +304,28 @@ server.registerTool(
|
|
|
94
304
|
.enum(["summary", "detailed"])
|
|
95
305
|
.optional()
|
|
96
306
|
.describe("'summary' (default) omits the line diff; 'detailed' includes it."),
|
|
307
|
+
wait: z
|
|
308
|
+
.number()
|
|
309
|
+
.int()
|
|
310
|
+
.min(1)
|
|
311
|
+
.max(50)
|
|
312
|
+
.optional()
|
|
313
|
+
.describe(
|
|
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.",
|
|
315
|
+
),
|
|
316
|
+
workspace: wsArg,
|
|
97
317
|
},
|
|
98
318
|
},
|
|
99
|
-
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)
|
|
100
329
|
const summarizeComment = (c: {
|
|
101
330
|
thread_id: string
|
|
102
331
|
author: string
|
|
@@ -121,20 +350,76 @@ server.registerTool(
|
|
|
121
350
|
})
|
|
122
351
|
}
|
|
123
352
|
|
|
353
|
+
// Long-poll (self-host shim flavor): the /v1 API has no blocking endpoint,
|
|
354
|
+
// so poll every 2.5s until the human acts or the wait runs out — the same
|
|
355
|
+
// contract as the remote server's wait on a coarser clock. "Acts" = the
|
|
356
|
+
// round changes OR the open-comment count moves (so waiting works with no
|
|
357
|
+
// round open, exactly like the server's comment.created wake). Transient
|
|
358
|
+
// errors retry; they never end the wait early. A settled round that still
|
|
359
|
+
// applies to the current head is already actionable and returns at once.
|
|
360
|
+
if (wait) {
|
|
361
|
+
const deadline = Date.now() + wait * 1000
|
|
362
|
+
const snap = () =>
|
|
363
|
+
Promise.all([
|
|
364
|
+
client.get(short_id),
|
|
365
|
+
client.getReview(short_id),
|
|
366
|
+
client.listComments(short_id, "open"),
|
|
367
|
+
]).then(([art, rev, open]) => {
|
|
368
|
+
const round = rev.pending ?? rev.rounds[0] ?? null
|
|
369
|
+
return {
|
|
370
|
+
key: `${round?.id ?? "none"}:${round?.state ?? "none"}:${open.length}`,
|
|
371
|
+
actionable:
|
|
372
|
+
!!round && round.state !== "pending" && round.version >= art.current_version,
|
|
373
|
+
}
|
|
374
|
+
})
|
|
375
|
+
let baseline: string | null = null
|
|
376
|
+
for (;;) {
|
|
377
|
+
const cur = await snap().catch(() => null)
|
|
378
|
+
if (cur) {
|
|
379
|
+
if (baseline === null) {
|
|
380
|
+
baseline = cur.key
|
|
381
|
+
if (cur.actionable) break
|
|
382
|
+
} else if (cur.key !== baseline) break
|
|
383
|
+
}
|
|
384
|
+
if (Date.now() >= deadline) break
|
|
385
|
+
await new Promise((r) => setTimeout(r, 2500))
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
124
389
|
const a = await client.get(short_id)
|
|
125
390
|
const head = a.current_version
|
|
126
391
|
const to = Math.min(head, Math.max(1, to_version ?? head))
|
|
127
392
|
const since = Math.min(to, Math.max(1, since_version ?? to - 1))
|
|
128
393
|
const history = a.versions.slice().sort((x, y) => y.n - x.n)
|
|
129
394
|
const newVersions = history.filter((v) => v.n > since && v.n <= to)
|
|
130
|
-
const [open, outdated, addressed] = await Promise.all([
|
|
395
|
+
const [open, outdated, addressed, reviewState] = await Promise.all([
|
|
131
396
|
client.listComments(short_id, "open"),
|
|
132
397
|
client.listComments(short_id, "outdated"),
|
|
133
398
|
client.listComments(short_id, "addressed"),
|
|
399
|
+
client.getReview(short_id).catch(() => ({ rounds: [], pending: null })),
|
|
134
400
|
])
|
|
401
|
+
const round = reviewState.pending ?? reviewState.rounds[0] ?? null
|
|
402
|
+
const review = round
|
|
403
|
+
? {
|
|
404
|
+
state: round.state,
|
|
405
|
+
version: round.version,
|
|
406
|
+
requested_at: round.created_at,
|
|
407
|
+
resolved_at: round.resolved_at,
|
|
408
|
+
note: round.note,
|
|
409
|
+
}
|
|
410
|
+
: null
|
|
411
|
+
const reviewBit = review
|
|
412
|
+
? review.state === "pending"
|
|
413
|
+
? ` Review requested on v${review.version} — waiting for the human.`
|
|
414
|
+
: review.state === "sent_back"
|
|
415
|
+
? ` The human sent back their review of v${review.version} — read the open threads, revise, and re-request.`
|
|
416
|
+
: ` The human approved v${review.version} — you're clear to proceed.`
|
|
417
|
+
: ""
|
|
135
418
|
let entryDiff: string | undefined
|
|
136
419
|
if (response_format === "detailed" && since < to) {
|
|
137
|
-
|
|
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")
|
|
138
423
|
entryDiff = d.ops
|
|
139
424
|
.map((o) => `${o.t === "add" ? "+" : o.t === "del" ? "-" : " "} ${o.line}`)
|
|
140
425
|
.join("\n")
|
|
@@ -143,10 +428,11 @@ server.registerTool(
|
|
|
143
428
|
const addressedBit = addressed.length ? ` ${addressed.length} addressed (pending review).` : ""
|
|
144
429
|
const summary =
|
|
145
430
|
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}`
|
|
431
|
+
? `You're up to date on "${a.title}" (v${head}); ${open.length} open comment(s).${addressedBit}${outdatedBit}${reviewBit}`
|
|
432
|
+
: `"${a.title}": ${newVersions.length} new version(s) since v${since} (now v${to}). ${open.length} open comment(s).${addressedBit}${outdatedBit}${reviewBit}`
|
|
148
433
|
return json({
|
|
149
434
|
summary,
|
|
435
|
+
review,
|
|
150
436
|
short_id,
|
|
151
437
|
since,
|
|
152
438
|
to,
|
|
@@ -170,7 +456,7 @@ server.registerTool(
|
|
|
170
456
|
"comment",
|
|
171
457
|
{
|
|
172
458
|
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).",
|
|
459
|
+
"Leave feedback, reply in a thread, react, 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`. Pass `react` with a `comment_id` (or `reply_to` to hit the thread's latest comment) to acknowledge feedback without the noise of a reply — the loop's minimum ack. Resolve/reopen by passing `set_state` with a `comment_id` from the thread (or the comment you just left).",
|
|
174
460
|
inputSchema: {
|
|
175
461
|
short_id: z.string(),
|
|
176
462
|
body: z
|
|
@@ -182,16 +468,24 @@ server.registerTool(
|
|
|
182
468
|
.optional()
|
|
183
469
|
.describe("A thread id to reply in; omit to start a new thread."),
|
|
184
470
|
quote: z.string().optional().describe("Exact text to anchor a NEW comment to."),
|
|
471
|
+
react: z
|
|
472
|
+
.enum(["👍", "❤️", "🎉", "😄", "👀", "🙏", "🚀", "👎"])
|
|
473
|
+
.optional()
|
|
474
|
+
.describe("React to a comment (with `comment_id` or `reply_to`) — 👍 is the loop's ack."),
|
|
185
475
|
set_state: z.enum(["resolved", "open"]).optional().describe("Resolve or reopen a thread."),
|
|
186
476
|
comment_id: z
|
|
187
477
|
.string()
|
|
188
478
|
.optional()
|
|
189
|
-
.describe("A comment in the thread to set_state on (when not posting)."),
|
|
479
|
+
.describe("A comment in the thread to react to / set_state on (when not posting)."),
|
|
480
|
+
workspace: wsArg,
|
|
190
481
|
},
|
|
191
482
|
},
|
|
192
|
-
async ({ short_id, body, reply_to, quote, set_state, comment_id }) => {
|
|
193
|
-
|
|
194
|
-
|
|
483
|
+
async ({ short_id, body, reply_to, quote, react, set_state, comment_id, workspace: ws }) => {
|
|
484
|
+
const client = clientFor(ws)
|
|
485
|
+
if (!body && !set_state && !react)
|
|
486
|
+
return text(
|
|
487
|
+
"Provide `body` (to comment), `react` (to acknowledge), or `set_state` (to resolve/reopen).",
|
|
488
|
+
)
|
|
195
489
|
let posted: Awaited<ReturnType<typeof client.createComment>> | undefined
|
|
196
490
|
if (body) {
|
|
197
491
|
const anchor = quote ? { type: "TextQuoteSelector", exact: quote } : undefined
|
|
@@ -202,6 +496,32 @@ server.registerTool(
|
|
|
202
496
|
author: "agent",
|
|
203
497
|
})
|
|
204
498
|
}
|
|
499
|
+
let reactNote = ""
|
|
500
|
+
if (react) {
|
|
501
|
+
// The ack target: an explicit comment, else the newest comment in the
|
|
502
|
+
// thread by someone ELSE — never the agent's own just-posted reply. One
|
|
503
|
+
// unfiltered fetch covers every thread state (the human may have replied
|
|
504
|
+
// on a resolved thread).
|
|
505
|
+
const all = await client.listComments(short_id)
|
|
506
|
+
let target = comment_id
|
|
507
|
+
if (!target && reply_to) {
|
|
508
|
+
const thread = all
|
|
509
|
+
.filter((cm) => cm.thread_id === reply_to)
|
|
510
|
+
.sort((x, y) => x.created_at.localeCompare(y.created_at))
|
|
511
|
+
const other = [...thread].reverse().find((cm) => cm.author !== "agent")
|
|
512
|
+
target = (other ?? thread[thread.length - 1])?.id
|
|
513
|
+
}
|
|
514
|
+
if (!target)
|
|
515
|
+
return text("`react` needs a `comment_id` or a `reply_to` thread to acknowledge.")
|
|
516
|
+
// The /react route TOGGLES; skipping an already-present emoji keeps a
|
|
517
|
+
// retried ack from silently removing it.
|
|
518
|
+
if (all.find((cm) => cm.id === target)?.reactions?.[react]?.length) {
|
|
519
|
+
reactNote = ` · already acknowledged with ${react}`
|
|
520
|
+
} else {
|
|
521
|
+
await client.react(short_id, target, react)
|
|
522
|
+
reactNote = ` · acknowledged with ${react}`
|
|
523
|
+
}
|
|
524
|
+
}
|
|
205
525
|
let stateNote = ""
|
|
206
526
|
if (set_state) {
|
|
207
527
|
const ref = posted?.id ?? comment_id
|
|
@@ -216,9 +536,12 @@ server.registerTool(
|
|
|
216
536
|
const where = reply_to
|
|
217
537
|
? `replied in thread ${posted.thread_id}`
|
|
218
538
|
: `new thread ${posted.thread_id}`
|
|
219
|
-
return text(
|
|
539
|
+
return text(
|
|
540
|
+
`${where} (comment ${posted.id})${quote ? ` on “${quote}”` : ""}${reactNote}${stateNote}.`,
|
|
541
|
+
)
|
|
220
542
|
}
|
|
221
|
-
return text(`
|
|
543
|
+
if (!set_state) return text(`Acknowledged${reactNote.replace(" · acknowledged", "")}.`)
|
|
544
|
+
return text(`Thread ${set_state === "resolved" ? "resolved" : "reopened"}${reactNote}.`)
|
|
222
545
|
},
|
|
223
546
|
)
|
|
224
547
|
|
|
@@ -227,9 +550,33 @@ server.registerTool(
|
|
|
227
550
|
"publish",
|
|
228
551
|
{
|
|
229
552
|
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.)",
|
|
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.)",
|
|
231
554
|
inputSchema: {
|
|
232
|
-
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
|
+
),
|
|
233
580
|
filename: z
|
|
234
581
|
.string()
|
|
235
582
|
.optional()
|
|
@@ -239,10 +586,12 @@ server.registerTool(
|
|
|
239
586
|
.optional()
|
|
240
587
|
.describe("Omit to create a new artifact; pass it to add a version."),
|
|
241
588
|
title: z.string().optional(),
|
|
242
|
-
//
|
|
243
|
-
//
|
|
244
|
-
//
|
|
245
|
-
|
|
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(),
|
|
246
595
|
message: z.string().optional().describe("What changed in this version."),
|
|
247
596
|
for_review: z
|
|
248
597
|
.boolean()
|
|
@@ -252,42 +601,93 @@ server.registerTool(
|
|
|
252
601
|
.array(z.string())
|
|
253
602
|
.optional()
|
|
254
603
|
.describe("Thread ids this revision resolves (live publish) or addresses (proposal)."),
|
|
604
|
+
request_review: z
|
|
605
|
+
.boolean()
|
|
606
|
+
.optional()
|
|
607
|
+
.describe(
|
|
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.",
|
|
609
|
+
),
|
|
610
|
+
workspace: wsArg,
|
|
255
611
|
},
|
|
256
612
|
},
|
|
257
|
-
async ({
|
|
613
|
+
async ({
|
|
614
|
+
content,
|
|
615
|
+
edits,
|
|
616
|
+
base_version,
|
|
617
|
+
filename,
|
|
618
|
+
short_id,
|
|
619
|
+
title,
|
|
620
|
+
workspace_access,
|
|
621
|
+
link_role,
|
|
622
|
+
listed,
|
|
623
|
+
message,
|
|
624
|
+
for_review,
|
|
625
|
+
addresses,
|
|
626
|
+
request_review,
|
|
627
|
+
workspace: ws,
|
|
628
|
+
}) => {
|
|
629
|
+
const client = clientFor(ws)
|
|
630
|
+
if (content !== undefined && edits) return text("Provide `content` OR `edits`, not both.")
|
|
258
631
|
if (for_review) {
|
|
259
632
|
if (!short_id) return text("A proposal revises an EXISTING artifact — pass its short_id.")
|
|
260
|
-
|
|
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,
|
|
261
658
|
content,
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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,
|
|
272
669
|
})
|
|
670
|
+
} catch (e) {
|
|
671
|
+
return err(e instanceof Error ? e.message : "publish failed")
|
|
273
672
|
}
|
|
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
673
|
const note = addresses?.length ? ` · resolved ${addresses.length} thread(s)` : ""
|
|
674
|
+
const openNote =
|
|
675
|
+
a.opened_in_tab === false
|
|
676
|
+
? " No open Derive tab caught this push — open the url for the user if they should see it now."
|
|
677
|
+
: ""
|
|
284
678
|
return json({
|
|
285
679
|
published: true,
|
|
286
680
|
short_id: a.short_id,
|
|
681
|
+
...(a.review_requested ? { review_requested: true } : {}),
|
|
287
682
|
version: a.current_version,
|
|
288
683
|
url: a.url,
|
|
289
684
|
title: a.title,
|
|
290
|
-
|
|
685
|
+
listed: a.listed,
|
|
686
|
+
link_role: a.link_role,
|
|
687
|
+
...(a.opened_in_tab !== undefined ? { opened_in_tab: a.opened_in_tab } : {}),
|
|
688
|
+
note:
|
|
689
|
+
(short_id ? `Live — new version${note}.` : `Live — created "${a.title}"${note}.`) +
|
|
690
|
+
openNote,
|
|
291
691
|
})
|
|
292
692
|
},
|
|
293
693
|
)
|
|
@@ -304,4 +704,35 @@ server.registerResource(
|
|
|
304
704
|
async (uri) => ({ contents: [{ uri: uri.href, mimeType: "text/markdown", text: GUIDE }] }),
|
|
305
705
|
)
|
|
306
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
|
+
|
|
307
738
|
await server.connect(new StdioServerTransport())
|
package/LICENSE
DELETED
|
@@ -1,105 +0,0 @@
|
|
|
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.
|