@derive-to/mcp 0.5.1 → 0.6.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/src/client.ts CHANGED
@@ -17,6 +17,9 @@ export interface PublishArgs {
17
17
  slug?: string
18
18
  spa?: boolean
19
19
  message?: string
20
+ /** Browse tags to set on the artifact at publish time. Given ⇒ replaces the set;
21
+ * omitted ⇒ leaves existing tags untouched on a republish. */
22
+ tags?: string[]
20
23
  /** The v2 access triple for a NEW artifact (see access-model.md); ignored on a
21
24
  * republish. */
22
25
  workspaceAccess?: WorkspaceAccess
@@ -61,27 +64,36 @@ export interface ArtifactSummaryJson {
61
64
  workspace_access?: string
62
65
  link_role?: string
63
66
  listed?: string
67
+ /** Browse tags on the artifact — present when the server returns them (newer servers). */
68
+ tags?: string[]
69
+ current_content_type?: string | null
70
+ is_linked_bundle?: boolean
64
71
  }
65
72
 
66
- /** A revision submitted for human review instead of published live. */
67
- export interface ProposeArgs {
68
- /** Full content for the proposal. Omit when using `edits` instead. */
69
- content?: string | Uint8Array
70
- filename?: string
71
- message: string
72
- /** Thread ids this revision addresses (flip to `addressed`, resolve on approval). */
73
- addresses?: string[]
74
- /** Exact-match search/replace against the current stored source, INSTEAD of
75
- * `content`. */
76
- edits?: DocEdit[]
77
- baseVersion?: number
73
+ export interface TagCountJson {
74
+ tag: string
75
+ count: number
76
+ }
77
+ export interface TagSuggestionsJson {
78
+ current: string[]
79
+ suggested: TagCountJson[]
80
+ vocabulary: TagCountJson[]
78
81
  }
79
- export interface ProposalJson {
82
+ /** The result of adding/removing/replacing tags across one or many artifacts. */
83
+ export interface TagResultJson {
84
+ updated: number
85
+ skipped: number
86
+ failed: number
87
+ results: { short_id: string; tags: string[] }[]
88
+ }
89
+ export interface CollectionSummaryJson {
80
90
  id: string
81
- base_version: number
82
- addressed?: string[]
91
+ title: string
92
+ count: number
83
93
  }
84
94
 
95
+ /** A revision submitted for human review instead of published live. */
96
+
85
97
  export interface NewCommentArgs {
86
98
  body_md: string
87
99
  thread_id?: string
@@ -89,6 +101,7 @@ export interface NewCommentArgs {
89
101
  base_version?: number
90
102
  path?: string
91
103
  anchor?: unknown
104
+ visual_target?: string
92
105
  }
93
106
 
94
107
  export interface VersionJson {
@@ -118,17 +131,34 @@ export interface ArtifactJson {
118
131
  listed?: string
119
132
  current_version: number
120
133
  versions: VersionJson[]
134
+ /** Browse tags — present on the detail endpoint (newer servers). */
135
+ tags?: string[]
136
+ /** Collection ids this artifact belongs to — present on the detail endpoint. */
137
+ collections?: string[]
121
138
  /** Time-grouped version view (newest-first); present on the detail endpoint. */
122
139
  sessions?: SessionJson[]
123
140
  /** Publish-response extras (agent-credentialed publishes only). */
124
141
  review_requested?: boolean
125
142
  opened_in_tab?: boolean
143
+ linked_bundle?: {
144
+ schema: "derive.linked-bundle/v1"
145
+ purpose: string
146
+ members: {
147
+ id: string
148
+ ref: string
149
+ label: string
150
+ role?: string
151
+ note?: string
152
+ available: boolean
153
+ }[]
154
+ diagrams?: { id: string; title: string; type: "loop" | "graph" }[]
155
+ }
126
156
  }
127
157
 
128
158
  /** One review round: the human-ack primitive of the /derive loop. */
129
159
  export interface ReviewRoundJson {
130
160
  id: string
131
- state: "pending" | "sent_back" | "approved"
161
+ state: "pending" | "sent_back"
132
162
  version: number
133
163
  note: string | null
134
164
  created_at: string
@@ -147,12 +177,50 @@ export interface DiffJson {
147
177
 
148
178
  export interface ViewStatsJson {
149
179
  total: number
180
+ last24h: number
150
181
  unique: number
151
182
  perVersion: { version: number; count: number }[]
152
183
  daily: { day: string; count: number }[]
153
184
  recent: { viewer: string; kind: "user" | "anon"; at: string }[]
154
185
  }
155
186
 
187
+ /** A library is an access-scoped catalog of immutable template starters. */
188
+ export interface TemplateLibraryEntryJson {
189
+ id: string
190
+ library_id: string
191
+ source_version: number
192
+ kind: "artifact" | "context"
193
+ category: string
194
+ format: "md" | "html"
195
+ title: string
196
+ description: string
197
+ outcome: string
198
+ sections: string[]
199
+ inputs: { name: string; description: string; required?: boolean }[]
200
+ tags: string[]
201
+ created_at: string
202
+ }
203
+ export interface TemplateLibraryJson {
204
+ id: string
205
+ title: string
206
+ description: string
207
+ scope: "private" | "workspace" | "public"
208
+ created_at: string
209
+ updated_at: string | null
210
+ entry_count: number
211
+ entries?: TemplateLibraryEntryJson[]
212
+ }
213
+ export interface TemplateLibraryPageJson {
214
+ libraries: TemplateLibraryJson[]
215
+ truncated: boolean
216
+ next_cursor: string | null
217
+ }
218
+ export interface TemplateStarterJson {
219
+ entry: TemplateLibraryEntryJson
220
+ source: string
221
+ mime_type: string
222
+ }
223
+
156
224
  export interface ContentOpts {
157
225
  version?: number
158
226
  /** A heading slug (single-file) or page path (bundle, optionally page#slug). */
@@ -179,14 +247,48 @@ export interface OutlineSectionJson {
179
247
  chars: number
180
248
  }
181
249
 
250
+ export interface SearchOpts {
251
+ caseSensitive?: boolean
252
+ /** source (default): the exact stored bytes. text: the visible text (tags stripped). */
253
+ in?: "source" | "text"
254
+ /** Lines of surrounding context per match (default 0, max 5, server-clamped). */
255
+ context?: number
256
+ /** Cap on matches returned per artifact (default 40, max 200, server-clamped). */
257
+ maxMatches?: number
258
+ /** Single-artifact search only; ignored in workspace-wide mode. */
259
+ version?: number
260
+ }
261
+
182
262
  export interface DeriveClient {
183
- /** List the workspace's artifacts (optionally filtered by a title query). */
184
- list(query?: string): Promise<ArtifactSummaryJson[]>
263
+ /** List the workspace's artifacts (optionally filtered by a title query and/or a browse tag). */
264
+ list(query?: string, tag?: string, archived?: boolean): Promise<ArtifactSummaryJson[]>
265
+ /** Move an artifact onto or off the reversible archive shelf. */
266
+ archive(shortId: string, archived: boolean): Promise<{ archived: boolean }>
267
+ /** The workspace tag vocabulary (tag → count, most-used first). */
268
+ listTags(): Promise<TagCountJson[]>
269
+ /** Suggest tags for an artifact: its current tags, neighbors' tags, and the vocabulary. */
270
+ suggestTags(shortId: string): Promise<TagSuggestionsJson>
271
+ /** Add/remove/replace browse tags across one or more artifacts. `set` replaces the
272
+ * whole set (overriding add/remove); otherwise add then remove. */
273
+ tag(
274
+ shortIds: string[],
275
+ ops: { add?: string[]; remove?: string[]; set?: string[] },
276
+ ): Promise<TagResultJson>
277
+ /** The workspace's team-visible collections with item counts. */
278
+ listCollections(): Promise<CollectionSummaryJson[]>
279
+ /** Add artifacts to a collection identified by id OR name (created if a new name). */
280
+ collect(
281
+ shortIds: string[],
282
+ ref: string,
283
+ ): Promise<{ collection: { id: string; title: string }; added: number; skipped: number }>
185
284
  publish(args: PublishArgs): Promise<ArtifactJson>
186
285
  /** Submit a single-file revision for human review (does not go live). */
187
- propose(shortId: string, args: ProposeArgs): Promise<ProposalJson>
188
286
  get(shortId: string): Promise<ArtifactJson>
189
287
  getContent(shortId: string, opts?: ContentOpts): Promise<ContentResult>
288
+ /** Grep WITHIN one artifact (shortId set) or ACROSS the workspace (shortId
289
+ * omitted) — the same engine and ripgrep-style text report the remote MCP
290
+ * `search` tool returns, relayed here verbatim. */
291
+ search(shortId: string | undefined, query: string, opts?: SearchOpts): Promise<string>
190
292
  /** The heading (single-file) or page (bundle) outline. Empty `sections` on an
191
293
  * older server that doesn't understand `?outline=1` (it 400s or ignores it). */
192
294
  getOutline(
@@ -210,6 +312,10 @@ export interface DeriveClient {
210
312
  restore(shortId: string, version: number): Promise<ArtifactJson>
211
313
  /** Aggregated view analytics. */
212
314
  viewStats(shortId: string): Promise<ViewStatsJson>
315
+ /** Accessible public + workspace + personal libraries, for MCP resources only. */
316
+ listTemplateLibraries(cursor?: string): Promise<TemplateLibraryPageJson>
317
+ getTemplateLibrary(id: string): Promise<TemplateLibraryJson>
318
+ getTemplateStarter(libraryId: string, entryId: string): Promise<TemplateStarterJson>
213
319
  }
214
320
 
215
321
  export interface ClientOptions {
@@ -239,12 +345,151 @@ export function createClient(opts: ClientOptions): DeriveClient {
239
345
  }
240
346
 
241
347
  return {
242
- async list(query) {
243
- const q = query ? `?query=${encodeURIComponent(query)}` : ""
348
+ async list(query, tag, archived) {
349
+ const qs = new URLSearchParams()
350
+ if (query) qs.set("query", query)
351
+ if (tag) qs.set("tag", tag.trim().toLowerCase())
352
+ if (archived) qs.set("scope", "archived")
353
+ const q = qs.toString() ? `?${qs}` : ""
244
354
  const r = (await ok(await f(`${base}/v1/artifacts${q}`, { headers: authHeaders }))) as {
245
355
  artifacts: ArtifactSummaryJson[]
246
356
  }
247
- return r.artifacts
357
+ return r.artifacts.map((artifact) => ({
358
+ ...artifact,
359
+ is_linked_bundle: artifact.current_content_type === "text/x-derive-linked-bundle",
360
+ }))
361
+ },
362
+
363
+ async archive(shortId, archived) {
364
+ return ok(
365
+ await f(`${base}/v1/artifacts/${shortId}/archive`, {
366
+ method: archived ? "PUT" : "DELETE",
367
+ headers: authHeaders,
368
+ }),
369
+ ) as Promise<{ archived: boolean }>
370
+ },
371
+
372
+ async listTags() {
373
+ const r = (await ok(await f(`${base}/v1/tags`, { headers: authHeaders }))) as {
374
+ tags: TagCountJson[]
375
+ }
376
+ return (r.tags ?? []).slice().sort((a, b) => b.count - a.count || a.tag.localeCompare(b.tag))
377
+ },
378
+
379
+ async suggestTags(shortId) {
380
+ return ok(
381
+ await f(`${base}/v1/artifacts/${shortId}/tag-suggestions`, { headers: authHeaders }),
382
+ ) as Promise<TagSuggestionsJson>
383
+ },
384
+
385
+ async tag(shortIds, ops) {
386
+ // `set` replaces the whole set in one bulk call. Otherwise apply the additive `add`
387
+ // (the bulk-add route merges per artifact) and/or `remove` — each a bulk call over
388
+ // the same id set, so the server does the per-artifact authorization + skip counting.
389
+ const post = (path: string, body: unknown) =>
390
+ f(`${base}${path}`, {
391
+ method: "POST",
392
+ headers: { ...authHeaders, "content-type": "application/json" },
393
+ body: JSON.stringify(body),
394
+ })
395
+ if (ops.set) {
396
+ // No bulk "set" route; set each artifact's tags directly (the union with nothing).
397
+ const results: { short_id: string; tags: string[] }[] = []
398
+ let updated = 0
399
+ let failed = 0
400
+ for (const id of shortIds) {
401
+ try {
402
+ const r = (await ok(
403
+ await f(`${base}/v1/artifacts/${id}/tags`, {
404
+ method: "PUT",
405
+ headers: { ...authHeaders, "content-type": "application/json" },
406
+ body: JSON.stringify({ tags: ops.set }),
407
+ }),
408
+ )) as { tags: string[] }
409
+ results.push({ short_id: id, tags: r.tags })
410
+ updated++
411
+ } catch {
412
+ failed++
413
+ }
414
+ }
415
+ return { updated, skipped: 0, failed, results }
416
+ }
417
+ let updated = 0
418
+ let skipped = 0
419
+ let failed = 0
420
+ if (ops.add?.length) {
421
+ const r = (await ok(await post("/v1/bulk/tags", { shortIds, add: ops.add }))) as {
422
+ ok: number
423
+ skipped: number
424
+ failed: number
425
+ }
426
+ updated = r.ok
427
+ skipped = r.skipped
428
+ failed = r.failed
429
+ }
430
+ if (ops.remove?.length) {
431
+ // No bulk-remove route: read-modify-write each artifact via the single tag route.
432
+ const removeSet = new Set(ops.remove.map((t) => t.trim().toLowerCase()))
433
+ for (const id of shortIds) {
434
+ try {
435
+ const a = await (ok(
436
+ await f(`${base}/v1/artifacts/${id}`, { headers: authHeaders }),
437
+ ) as Promise<{
438
+ tags?: string[]
439
+ }>)
440
+ const next = (a.tags ?? []).filter((t) => !removeSet.has(t))
441
+ await ok(
442
+ await f(`${base}/v1/artifacts/${id}/tags`, {
443
+ method: "PUT",
444
+ headers: { ...authHeaders, "content-type": "application/json" },
445
+ body: JSON.stringify({ tags: next }),
446
+ }),
447
+ )
448
+ } catch {
449
+ /* a remove miss (not editable) is reflected by the add pass's skipped, or ignored */
450
+ }
451
+ }
452
+ }
453
+ return { updated, skipped, failed, results: [] }
454
+ },
455
+
456
+ async listCollections() {
457
+ const r = (await ok(await f(`${base}/v1/collections`, { headers: authHeaders }))) as {
458
+ collections: { id: string; title: string; count: number }[]
459
+ }
460
+ return r.collections.map((c) => ({ id: c.id, title: c.title, count: c.count }))
461
+ },
462
+
463
+ async collect(shortIds, ref) {
464
+ // Resolve `ref` against the workspace's collections by id OR title, else create it.
465
+ const name = ref.trim()
466
+ const cols = (
467
+ (await ok(await f(`${base}/v1/collections`, { headers: authHeaders }))) as {
468
+ collections: { id: string; title: string }[]
469
+ }
470
+ ).collections
471
+ const match = cols.find((c) => c.id === name || c.title.toLowerCase() === name.toLowerCase())
472
+ let collectionId = match?.id
473
+ let title = match?.title ?? ""
474
+ if (!collectionId) {
475
+ const created = (await ok(
476
+ await f(`${base}/v1/collections`, {
477
+ method: "POST",
478
+ headers: { ...authHeaders, "content-type": "application/json" },
479
+ body: JSON.stringify({ title: name }),
480
+ }),
481
+ )) as { id: string; title: string }
482
+ collectionId = created.id
483
+ title = created.title
484
+ }
485
+ const r = (await ok(
486
+ await f(`${base}/v1/bulk/collections`, {
487
+ method: "POST",
488
+ headers: { ...authHeaders, "content-type": "application/json" },
489
+ body: JSON.stringify({ shortIds, collectionIds: [collectionId] }),
490
+ }),
491
+ )) as { ok: number; skipped: number }
492
+ return { collection: { id: collectionId, title }, added: r.ok, skipped: r.skipped }
248
493
  },
249
494
 
250
495
  async publish(args) {
@@ -264,6 +509,7 @@ export function createClient(opts: ClientOptions): DeriveClient {
264
509
  slug: args.slug,
265
510
  spa: args.spa,
266
511
  message: args.message,
512
+ tags: args.tags,
267
513
  workspaceAccess: args.workspaceAccess,
268
514
  linkRole: args.linkRole,
269
515
  listed: args.listed,
@@ -277,29 +523,6 @@ export function createClient(opts: ClientOptions): DeriveClient {
277
523
  ) as Promise<ArtifactJson>
278
524
  },
279
525
 
280
- async propose(shortId, args) {
281
- const form = new FormData()
282
- if (args.edits) {
283
- form.append("edits", JSON.stringify(args.edits))
284
- if (args.baseVersion != null) form.append("base_version", String(args.baseVersion))
285
- } else {
286
- const bytes =
287
- typeof args.content === "string" || args.content === undefined
288
- ? new TextEncoder().encode(args.content ?? "")
289
- : args.content
290
- form.append("file", new Blob([bytes as BlobPart]), args.filename ?? "index.html")
291
- }
292
- form.append("message", args.message)
293
- if (args.addresses?.length) form.append("addresses", args.addresses.join(","))
294
- return ok(
295
- await f(`${base}/v1/artifacts/${shortId}/proposals`, {
296
- method: "POST",
297
- body: form,
298
- headers: authHeaders,
299
- }),
300
- ) as Promise<ProposalJson>
301
- },
302
-
303
526
  async get(shortId) {
304
527
  return ok(
305
528
  await f(`${base}/v1/artifacts/${shortId}`, { headers: authHeaders }),
@@ -334,6 +557,24 @@ export function createClient(opts: ClientOptions): DeriveClient {
334
557
  }
335
558
  },
336
559
 
560
+ async search(shortId, query, opts) {
561
+ const q = new URLSearchParams({ query })
562
+ if (opts?.caseSensitive) q.set("case_sensitive", "true")
563
+ if (opts?.in) q.set("in", opts.in)
564
+ if (opts?.context != null) q.set("context", String(opts.context))
565
+ if (opts?.maxMatches != null) q.set("max_matches", String(opts.maxMatches))
566
+ if (opts?.version != null) q.set("v", String(opts.version))
567
+ const path = shortId
568
+ ? `${base}/v1/artifacts/${shortId}/search?${q}`
569
+ : `${base}/v1/artifacts/search?${q}`
570
+ const res = await f(path, { headers: authHeaders })
571
+ if (!res.ok) {
572
+ const body = (await res.json().catch(() => ({}))) as { error?: string }
573
+ throw new Error(`derive ${res.status}: ${body.error ?? res.statusText}`)
574
+ }
575
+ return res.text()
576
+ },
577
+
337
578
  async getOutline(shortId, version) {
338
579
  const q = new URLSearchParams({ outline: "1" })
339
580
  if (version) q.set("v", String(version))
@@ -415,5 +656,30 @@ export function createClient(opts: ClientOptions): DeriveClient {
415
656
  await f(`${base}/v1/artifacts/${shortId}/analytics`, { headers: authHeaders }),
416
657
  ) as Promise<ViewStatsJson>
417
658
  },
659
+
660
+ async listTemplateLibraries(cursor) {
661
+ const query = new URLSearchParams({ limit: "100" })
662
+ if (cursor) query.set("cursor", cursor)
663
+ return ok(
664
+ await f(`${base}/v1/template-libraries?${query}`, { headers: authHeaders }),
665
+ ) as Promise<TemplateLibraryPageJson>
666
+ },
667
+
668
+ async getTemplateLibrary(id) {
669
+ return ok(
670
+ await f(`${base}/v1/template-libraries/${encodeURIComponent(id)}`, {
671
+ headers: authHeaders,
672
+ }),
673
+ ) as Promise<TemplateLibraryJson>
674
+ },
675
+
676
+ async getTemplateStarter(libraryId, entryId) {
677
+ return ok(
678
+ await f(
679
+ `${base}/v1/template-libraries/${encodeURIComponent(libraryId)}/entries/${encodeURIComponent(entryId)}/starter`,
680
+ { headers: authHeaders },
681
+ ),
682
+ ) as Promise<TemplateStarterJson>
683
+ },
418
684
  }
419
685
  }
@@ -0,0 +1,30 @@
1
+ // Choosing the upload filename for an inline `content` publish when the caller gave
2
+ // none. The Derive server types an artifact by its filename FIRST, so a blind
3
+ // `index.html` default silently stores Markdown as text/html — the page then renders
4
+ // the raw Markdown as markup and swallows tag-like text (the 2026-07 retype incident).
5
+ //
6
+ // These mirror the server's own sniff, inlined here because the stdio shim keeps NO
7
+ // @derive/core dependency at runtime (it is a thin HTTP client).
8
+
9
+ /** An HTML page opener: the doctype/`<html>` markers, plus the head/meta/style/
10
+ * title/body openers a headless designed page starts with — matched after skipping
11
+ * leading HTML comments (a comment alone never decides). A leading `<div>`/`<p>`/
12
+ * custom tag is NOT one: that is how HTML-flavored Markdown (a centered README)
13
+ * legitimately opens. Mirrors @derive/core's looksLikeHtmlDocument exactly. */
14
+ export const looksLikeHtmlDocument = (s: string): boolean => {
15
+ let head = s.replace(/^/, "").trimStart()
16
+ for (let i = 0; i < 8 && head.startsWith("<!--"); i++) {
17
+ const end = head.indexOf("-->")
18
+ if (end === -1) return false
19
+ head = head.slice(end + 3).trimStart()
20
+ }
21
+ return /^<(!doctype\s+html|html|head|body|meta|style|title)[\s/>]/i.test(head.slice(0, 64))
22
+ }
23
+
24
+ /** The fallback filename for inline content with none given: an HTML page →
25
+ * `index.html`, anything else → `index.md`. So Markdown is never stored as HTML.
26
+ * Caveat: fragment HTML that opens with a `<div>` (indistinguishable from
27
+ * HTML-flavored Markdown) still lands as `.md` — pass an explicit
28
+ * `filename` (or use `edits`) to keep it HTML. */
29
+ export const fallbackFilename = (content: string | undefined): string =>
30
+ looksLikeHtmlDocument(content ?? "") ? "index.html" : "index.md"