From ea041ef28d6aec30abcd0f36507e29ac49519122 Mon Sep 17 00:00:00 2001 From: Nico Ritschel Date: Mon, 2 Nov 2026 15:08:55 -0800 Subject: [PATCH 1/2] Add recoverable Trash and shared MCP library selection --- README.md | 15 +++- cloudflare/artifact-execution.ts | 1 + cloudflare/auth-flow.test.ts | 2 + cloudflare/backend.ts | 10 +++ cloudflare/files.ts | 7 ++ cloudflare/links.ts | 38 +++++++++-- cloudflare/managed-service.ts | 59 ++++++++++++++-- cloudflare/mcp.ts | 2 +- cloudflare/ownership.test.ts | 34 ++++++++++ cloudflare/ownership.ts | 46 +++++++++++-- cloudflare/script-backend.ts | 10 +++ cloudflare/script-guide.ts | 4 ++ cloudflare/tool-contract.ts | 31 ++++++++- cloudflare/worker.test.ts | 113 ++++++++++++++++++++++++++++++- cloudflare/workspace-tools.ts | 2 +- docs/authentication.md | 11 +-- docs/cloudflare.md | 5 +- e2e/gallery-onboarding.test.ts | 3 +- e2e/mcp-app.test.ts | 49 ++++++++++++++ package.json | 1 + src/gallery/client.tsx | 15 +++- src/gallery/hosted.tsx | 5 +- src/gallery/styles.ts | 5 ++ src/gallery/trash.tsx | 80 ++++++++++++++++++++++ src/gallery/types.ts | 4 +- src/gallery/workspace.tsx | 38 +++++++---- src/mcp/app-contract.ts | 3 +- src/mcp/discovery.test.ts | 6 +- src/mcp/discovery.ts | 4 +- src/mcp/gallery-contract.ts | 1 + src/mcp/workspace-contract.ts | 9 ++- src/runtime/mcp-app.ts | 27 ++++---- src/runtime/mcp-workspace.ts | 54 +++++++++++---- 33 files changed, 611 insertions(+), 83 deletions(-) create mode 100644 src/gallery/trash.tsx diff --git a/README.md b/README.md index 856c76c..8ffc353 100644 --- a/README.md +++ b/README.md @@ -112,15 +112,24 @@ https://your-artifacts-host/mcp ``` For the local host, use `http://127.0.0.1:4786/mcp`. Remote clients use the -configured deployment's sign-in flow. Optional `workspace` and `library` query -parameters select a workspace or the team library. +configured deployment's sign-in flow. One connection supports personal and team +libraries: use `library_scope: "private"` or `library_scope: "team"` on a tool call, +or choose the library in the MCP gallery. Optional `workspace` and `library` URL +parameters remain supported as connection defaults. The connection advertises nine model tools: `artifact_guide`, `artifact_list`, `artifact_read`, `artifact_write`, `artifact_edit`, `artifact_open`, -`artifacts_discover`, `artifacts_query`, and `artifacts_execute`. All 48 hosted +`artifacts_discover`, `artifacts_query`, and `artifacts_execute`. All 53 hosted operations remain available through that same connection. Use `artifacts_discover` to find an operation and retrieve its exact argument schema and relevant guide; call it through `artifacts_query` for reads or `artifacts_execute` for actions. + +In the hosted gallery, **More actions → Delete** moves an artifact or script to +recoverable **Trash**. Links stop working and schedules pause; source history, +data, files and secrets are preserved. Restore it from Trash to recover its +original link. Schedules stay paused until explicitly resumed. MCP exposes +`artifact_delete`, `script_delete`, `artifacts_trash`, `artifact_undelete` and +`script_undelete` through discovery. Local file connections expose their supported artifact operations through the same interface. No configuration switch is needed. diff --git a/cloudflare/artifact-execution.ts b/cloudflare/artifact-execution.ts index 8554169..7ce38a3 100644 --- a/cloudflare/artifact-execution.ts +++ b/cloudflare/artifact-execution.ts @@ -59,6 +59,7 @@ export class ArtifactExecution { if (!["set", "pause", "resume"].includes(action)) throw new Error("Invalid schedule action"); return this.storage.transaction(async storage => { const previous = this.readSchedule(); + if (action !== "pause" && await storage.get("trashed")) throw new Error("Item is in Trash"); if (action !== "set" && !previous) throw new Error("No schedule configured"); const active = this.readActive(); if (action !== "pause" && !active?.code) throw new Error("Artifact has no validated server"); diff --git a/cloudflare/auth-flow.test.ts b/cloudflare/auth-flow.test.ts index 0dc4f24..46efc6d 100644 --- a/cloudflare/auth-flow.test.ts +++ b/cloudflare/auth-flow.test.ts @@ -513,6 +513,8 @@ test("a service token manages the team library over MCP and confers nothing outs expect(linked.isError, JSON.stringify(linked.content)).not.toBe(true); // The service account has no personal library to fall back to. + expect((await service.callTool({ name: "artifact_list", arguments: { library_scope: "private" } })).isError).toBe(true); + expect((await service.callTool({ name: "artifacts_execute", arguments: { operation: "artifact_delete", arguments: { name: "service-report", library_scope: "private" } } })).isError).toBe(true); for (const query of ["workspace=test", "workspace=test&library=private"]) { const response = await fetch(`${origin}/mcp?${query}`, { headers: bearer }); expect(response.status).toBe(403); diff --git a/cloudflare/backend.ts b/cloudflare/backend.ts index d82daee..88ca7be 100644 --- a/cloudflare/backend.ts +++ b/cloudflare/backend.ts @@ -81,7 +81,15 @@ export class ArtifactBackend extends DurableObject { return active ? { version_id: active.version_id, revision: active.revision, has_server: active.code !== null } : null; } runs(input: { limit?: number } = {}) { return this.execution.runs(input.limit); } + async setTrashed(trashed: boolean) { + if (trashed) await this.ctx.storage.put("trashed", true); + if (await this.execution.get()) await this.execution.update({ action: "pause" }); + await this.ctx.storage.deleteAlarm(); + if (!trashed) await this.ctx.storage.put("trashed", false); + // Restoration deliberately leaves schedules paused until explicitly resumed. + } async schedule(input: ArtifactScheduleInput = {}) { + if (input.action !== undefined && input.action !== "get" && input.action !== "pause" && await this.ctx.storage.get("trashed")) throw new Error("Item is in Trash"); if (input.request) { nativeRequest(input.request); input = { ...input, request: { ...input.request, method: input.request.method ?? "GET", headers: input.request.headers ?? [] } }; @@ -95,6 +103,7 @@ export class ArtifactBackend extends DurableObject { return this.execution.update(input); } async alarm() { + if (await this.ctx.storage.get("trashed")) return; const claimed = await this.execution.claim(); if (claimed) await this.runScheduled(claimed.schedule.request, "schedule", claimed); } @@ -131,6 +140,7 @@ export class ArtifactBackend extends DurableObject { } private async execute(input: BackendCode, request: Request, claimedRun?: { id: string; started: number }): Promise { + if (await this.ctx.storage.get("trashed")) throw new Error("Item is in Trash"); const active = input.version_id ? undefined : await this.execution.active(); const revision = input.version_id ?? (active?.hash === input.hash ? active.version_id : input.hash); const run = claimedRun ?? this.execution.start(revision, input.trigger ?? "http"); diff --git a/cloudflare/files.ts b/cloudflare/files.ts index 4c49f99..52c8c11 100644 --- a/cloudflare/files.ts +++ b/cloudflare/files.ts @@ -54,7 +54,13 @@ export class ArtifactFiles extends DurableObject { private prefix() { return `canvases/${this.ctx.id.toString()}/`; } private key(id: string) { return this.prefix() + id; } + async setTrashed(trashed: boolean) { + await this.ctx.storage.put("trashed", trashed); + if (trashed) this.ctx.storage.sql.exec("delete from file_grants"); + } + async request(input: ArtifactFileRequest): Promise { + if (await this.ctx.storage.get("trashed")) throw new ArtifactFileError("Item is in Trash", 404); validateFileRequest(input); if (!this.env.FILES) throw new ArtifactFileError("Artifact file storage is not configured", 503); if (input.operation === "list") { @@ -111,6 +117,7 @@ export class ArtifactFiles extends DurableObject { async fetch(request: Request): Promise { const headers = transferHeaders(); try { + if (await this.ctx.storage.get("trashed")) throw new ArtifactFileError("Transfer not found", 404); const match = new URL(request.url).pathname.match(TRANSFER_PATH); if (!match || match[1] !== this.ctx.id.toString() || !ID.test(match[2]!)) throw new ArtifactFileError("Transfer not found", 404); const row = this.ctx.storage.sql.exec<{ value: string }>("select value from file_grants where token = ? and expires > ?", match[2]!, Date.now()).toArray()[0]; diff --git a/cloudflare/links.ts b/cloudflare/links.ts index 1d50344..e18d804 100644 --- a/cloudflare/links.ts +++ b/cloudflare/links.ts @@ -1,6 +1,11 @@ import { DurableObject } from "cloudflare:workers"; import { LibraryOwnership, type LibrarySelection, type OwnershipEnvironment } from "./ownership"; import { GallerySubscriptions } from "./gallery-subscriptions"; +import type { ArtifactBackend } from "./backend"; +import type { ScriptBackend } from "./script-backend"; +import type { ArtifactFiles } from "./files"; + +type TrashEnvironment = { BACKENDS?: DurableObjectNamespace; SCRIPT_BACKENDS?: DurableObjectNamespace; FILE_BACKENDS?: DurableObjectNamespace }; export type ArtifactTarget = { libraryKey: string; workspace: string; kind: "artifact" | "script"; name: string }; export type LiveRevision = { id: string; revision: number; revision_token: string }; @@ -42,6 +47,21 @@ export class ArtifactLinks extends DurableObject { return result; } catalog(input: Parameters[0]) { return this.ownership.catalog(input); } + trash(input: Parameters[0]) { return this.ownership.trash(input); } + async setTrashed(selection: LibrarySelection & { name: string }, trashed: boolean) { + const target = await this.ownership.setTrashed(selection, trashed, async (target, value) => { + // Invalidate compiles admitted before deletion, including ones that finish + // after the item is restored. Source and storage remain recoverable. + if (value) this.beginGeneration(target); + const env = this.env as TrashEnvironment; + const backends = target.kind === "artifact" ? env.BACKENDS : env.SCRIPT_BACKENDS; + const identity = JSON.stringify([target.libraryKey, target.workspace, target.name]); + if (backends) await backends.getByName(identity).setTrashed(value); + if (target.kind === "artifact" && env.FILE_BACKENDS) await env.FILE_BACKENDS.getByName(identity).setTrashed(value); + }); + this.subscriptions.changed(selection.libraryKey, target.workspace); + return { workspace: target.workspace, kind: target.kind, name: target.name, trashed }; + } fetch(request: Request) { return this.subscriptions.accept(request); } changed(target: ArtifactTarget) { this.subscriptions.changed(this.ownership.owner(target), target.workspace); } nativeAppChanged(selection: { libraryKey: string; workspace: string }) { this.subscriptions.changed(selection.libraryKey, selection.workspace); } @@ -51,15 +71,18 @@ export class ArtifactLinks extends DurableObject { get(slug: string): ArtifactLink | null { const row = this.ctx.storage.sql.exec<{ value: string }>("select value from links where slug = ?", slug).toArray()[0]; - return row ? readLink(row.value) : null; + const link = row ? readLink(row.value) : null; + return link && !this.ownership.isTrashed(link) ? link : null; } find(target: ArtifactTarget): ArtifactLink | null { const row = this.ctx.storage.sql.exec<{ value: string }>("select value from links where target = ?", key(target)).toArray()[0]; - return row ? readLink(row.value) : null; + return row && !this.ownership.isTrashed(target) ? readLink(row.value) : null; } check(target: ArtifactTarget, candidate: string): string { const slug = validateSlug(candidate); - const taken = this.get(slug); + // Trashed links reserve their slug so restoring cannot hijack another URL. + const row = this.ctx.storage.sql.exec<{ value: string }>("select value from links where slug=?", slug).toArray()[0]; + const taken = row ? readLink(row.value) : null; if (taken && key(taken) !== key(target)) throw new Error("Slug is already in use"); return slug; } @@ -69,7 +92,7 @@ export class ArtifactLinks extends DurableObject { } /** Keep desired URL metadata with invalid drafts without exposing a route. */ stage(target: ArtifactTarget, generation: number, settings: LinkUpdate): boolean { - if (this.generation(target) !== generation) return false; + if (this.ownership.isTrashed(target) || this.generation(target) !== generation) return false; const previous = this.draft(target); const pending = { ...previous, ...(settings.slug === undefined ? {} : { slug: validateSlug(settings.slug) }), @@ -84,6 +107,10 @@ export class ArtifactLinks extends DurableObject { } /** Issue before saving/compiling. A later edit supersedes every older activation. */ begin(target: ArtifactTarget): number { + if (this.ownership.isTrashed(target)) throw new Error("Item is in Trash"); + return this.beginGeneration(target); + } + private beginGeneration(target: ArtifactTarget): number { const generation = this.generation(target) + 1; this.ctx.storage.sql.exec("insert into generations values (?, ?) on conflict(target) do update set generation = excluded.generation", key(target), generation); return generation; @@ -93,7 +120,7 @@ export class ArtifactLinks extends DurableObject { return this.ctx.storage.transactionSync(() => this.commitInTransaction(target, generation, update)); } private commitInTransaction(target: ArtifactTarget, generation: number, update: LinkUpdate): ArtifactLink | null { - if (this.generation(target) !== generation) return null; + if (this.ownership.isTrashed(target) || this.generation(target) !== generation) return null; const previous = this.find(target); update = { ...this.draft(target), ...update }; const slug = this.check(target, update.slug ?? previous?.slug ?? target.name); @@ -107,6 +134,7 @@ export class ArtifactLinks extends DurableObject { } set(input: ArtifactTarget & { slug: string; access?: "private" | "public" }): ArtifactLink { return this.ctx.storage.transactionSync(() => { + if (this.ownership.isTrashed(input)) throw new Error("Item is in Trash"); this.check(input, input.slug); // Explicit metadata actions replace draft intent, including pending public // access, so a subsequent source fix cannot undo a revocation. diff --git a/cloudflare/managed-service.ts b/cloudflare/managed-service.ts index a6aad12..70ebd34 100644 --- a/cloudflare/managed-service.ts +++ b/cloudflare/managed-service.ts @@ -19,7 +19,7 @@ import { CLOUD_MCP_TOOLS } from "./tool-contract"; import * as toolValidators from "../dist/cloudflare/tool-validators.js"; const text = (value: unknown): CallToolResult => ({ content: [{ type: "text", text: JSON.stringify(value, null, 2) }] }); -type Selection = { workspace?: string; name?: string; version_id?: string; event_id?: string }; +type Selection = { workspace?: string; name?: string; version_id?: string; event_id?: string; library_scope?: "private" | "team" }; /** Management requests select an owner; execution continues on immutable storage. */ export class ManagedArtifactService { @@ -44,6 +44,15 @@ export class ManagedArtifactService { this.plugins, this.fileStorage); } + private scoped(scope: unknown): ManagedArtifactService { + if (scope === undefined) return this; + if (scope !== "private" && scope !== "team") throw new Error("Library scope must be private or team"); + if (scope === "private" && this.privateKey === "team") throw new Error("Service tokens manage the team library only"); + const libraryKey = scope === "team" ? "team" : this.privateKey; + return libraryKey === this.libraryKey ? this : new ManagedArtifactService(this.env, this.workspace, libraryKey, + this.privateKey, this.origin, this.publicOrigin, this.plugins); + } + private selection(kind: "artifact" | "script", selection: Selection): LibrarySelection { return { libraryKey: this.libraryKey, workspace: this.workspace, kind, ...selection }; } @@ -57,7 +66,24 @@ export class ManagedArtifactService { private async callSelectedTool(target: ArtifactTarget, name: string, args: Record) { const changesGallery = /^(artifact|script)_(write|edit|restore|remix|import|open|link)$/.test(name); - try { return await this.physical(target).callTool(name, { ...args, workspace: target.workspace }); } + try { + const result = await this.physical(target).callTool(name, { ...args, workspace: target.workspace }); + const scope = this.libraryKey === "team" ? "team" : "private"; + // Both full previews and compact mutation results must reopen in the + // logical library, even after an item moves to different physical storage. + const scopedArtifact = (value: unknown) => value && typeof value === "object" ? { ...value, library_scope: scope } : value; + if (result._meta?.artifact) result._meta = { ...result._meta, artifact: scopedArtifact(result._meta.artifact) }; + const content = result.structuredContent; + if (content) { + const preview = content.preview as Record | undefined; + result.structuredContent = { ...content, + ...(content.artifact ? { artifact: scopedArtifact(content.artifact) } : {}), + ...(content.appPreview ? { appPreview: scopedArtifact(content.appPreview) } : {}), + ...(preview?.artifact ? { preview: { ...preview, artifact: scopedArtifact(preview.artifact) } } : {}), + }; + } + return result; + } finally { // A failed compile may still have saved a draft. Resolve current ownership // after the operation, including writes admitted before a library move. @@ -79,6 +105,10 @@ export class ManagedArtifactService { } async callTool(name: string, args: Record): Promise { + if (args.library_scope !== undefined) { + const { library_scope, ...input } = args; + return this.scoped(library_scope).callTool(name, input); + } if (isDiscoveryTool(name)) { return callDiscoveryTool(CLOUD_MCP_TOOLS, name, args, async (operation, input) => { validateToolArguments(toolValidators, operation, input); @@ -88,6 +118,17 @@ export class ManagedArtifactService { }); } if (ARTIFACTS_SETTINGS_TOOLS.some(tool => tool.name === name)) return settingsTool(this, name, args); + if (name === "artifacts_trash") { + const items = await this.links.trash({ libraryKey: this.libraryKey, workspace: args.all === true ? undefined : this.workspace, offset: args.offset as number | undefined }); + return text({ items, next_offset: items.length === 100 ? (args.offset as number | undefined ?? 0) + 100 : null }); + } + if (/^(artifact|script)_(delete|undelete)$/.test(name)) { + const kind = name.startsWith("script_") ? "script" : "artifact"; + const result = await this.links.setTrashed({ ...this.selection(kind, { + name: args.name as string, ...(typeof args.workspace === "string" ? { workspace: args.workspace } : {}), + }), name: args.name as string }, name.endsWith("_delete")); + return text({ ok: true, ...result }); + } if (name === "artifacts_tool") return galleryAction(this, args); if (name === "artifacts_source") return gallerySource(this, args); if (["artifacts_library", "artifacts_working", "artifacts_search", "artifacts_mentions"].includes(name)) return workspaceTool(this, name, args); @@ -155,8 +196,14 @@ export class ManagedArtifactService { } pluginCall(request: PluginRequest) { return this.physical({ libraryKey: this.libraryKey, workspace: this.workspace }).pluginCall(request); } - async snapshot(selection: Selection) { return (await this.service("artifact", selection)).snapshot(selection); } - async scriptReadSource(selection: Selection) { return (await this.service("script", selection)).scriptReadSource(selection); } + async snapshot(selection: Selection) { + const { library_scope, ...input } = selection; + return (await this.scoped(library_scope).service("artifact", input)).snapshot(input); + } + async scriptReadSource(selection: Selection) { + const { library_scope, ...input } = selection; + return (await this.scoped(library_scope).service("script", input)).scriptReadSource(input); + } async request(selection: Selection, request: ArtifactHttpRequest) { return (await this.service("artifact", selection)).request(selection, request); } async fileRequest(selection: Selection, request: ArtifactFileRequest) { return (await this.service("artifact", selection)).fileRequest(selection, request); } async preview(snapshot: Awaited>) { @@ -185,7 +232,7 @@ export class ManagedArtifactService { for (const row of rows) { const name = method === "listDrafts" && kind === "artifact" ? row.id! : row.name; const key = JSON.stringify(kind === "script" ? ["script", row.workspace, name] : [row.workspace, name]); - const item: GalleryArtifact = artifacts.get(key) ?? { key, name, workspace: row.workspace, working: false, kind, versions: [] }; + const item: GalleryArtifact = artifacts.get(key) ?? { key, name, workspace: row.workspace, library_scope: this.libraryKey === "team" ? "team" : "private", working: false, kind, versions: [] }; if (method === "listDrafts") item.working = true; else item.versions.push({ id: row.version_id ?? row.id!, revision: row.revision as number, createdAt: row.created_at!, reason: row.reason as string, serveCount: kind === "artifact" ? row.serve_count as number : 0 }); artifacts.set(key, item); @@ -205,7 +252,7 @@ export class ManagedArtifactService { ? await this.nativeController().list({ owner: this.libraryKey, workspace: all ? undefined : this.workspace, offset }) : null; hasMore ||= workers?.next_offset != null; - return { libraryScope: this.libraryKey === "team" ? "team" : "private", capabilities: { editing: true, scripts: true, links: true, moves: true, nativeApps: !!this.env.NATIVE_APPS, subscriptions: true, subscriptionTransport: "events" as const }, workspace: this.workspace, + return { libraryScope: this.libraryKey === "team" ? "team" : "private", capabilities: { editing: true, scripts: true, links: true, moves: true, trash: true, nativeApps: !!this.env.NATIVE_APPS, subscriptions: true, subscriptionTransport: "events" as const }, workspace: this.workspace, ...(workers ? { workerApps: workers.apps.map(app => ({ ...app, kind: "worker" as const, key: JSON.stringify(["worker", app.workspace, app.name]) })), nativeAppProviders: workers.providers } : {}), artifacts: [...artifacts.values()].sort((a, b) => a.name.localeCompare(b.name) || a.workspace.localeCompare(b.workspace)), nextOffset: hasMore ? offset + 100 : null }; } diff --git a/cloudflare/mcp.ts b/cloudflare/mcp.ts index d8a7d40..a51effb 100644 --- a/cloudflare/mcp.ts +++ b/cloudflare/mcp.ts @@ -27,7 +27,7 @@ export async function handleCloudMcp(request: Request, service: Pick ({ resources: [resource] })); - server.setRequestHandler(ListResourceTemplatesRequestSchema, async () => ({ resourceTemplates: [{ uriTemplate: "artifact://project?workspace={workspace}&kind={kind}&name={name}", name: "Artifact or script project", description: "Read a complete artifact or script project in the authenticated library.", mimeType: "application/json" }] })); + server.setRequestHandler(ListResourceTemplatesRequestSchema, async () => ({ resourceTemplates: [{ uriTemplate: "artifact://project?workspace={workspace}&kind={kind}&name={name}{&library_scope}", name: "Artifact or script project", description: "Read a complete artifact or script project in the authenticated personal or team library.", mimeType: "application/json" }] })); server.setRequestHandler(ReadResourceRequestSchema, async ({ params }) => { if (params.uri.startsWith("artifact://")) { const { kind, ...selection } = parseArtifactResourceUri(params.uri); diff --git a/cloudflare/ownership.test.ts b/cloudflare/ownership.test.ts index 9a3fe07..33ac51f 100644 --- a/cloudflare/ownership.test.ts +++ b/cloudflare/ownership.test.ts @@ -52,6 +52,40 @@ test("gallery subscriptions isolate owners and workspaces and follow moved physi for (const kind of ["artifact", "script"] as const) { const binding = kind === "artifact" ? "LIBRARIES" : "SCRIPTS"; + test(`${kind}: Trash revokes admission and links, preserves revisions and blocks overwrites until restored by the owner`, async () => { + const workspace = `trash-${kind}`, name = "retained", original = { libraryKey: "trash-alice", workspace, kind, name }; + await call(binding, original.libraryKey, "writeDraft", { workspace, name, source: "retained source" }); + if (kind === "artifact") await call(binding, original.libraryKey, "recordServe", { workspace, name, source: "retained source", runtime: "test", mode: "live", initial_state: {} }); + const version = (await call(binding, original.libraryKey, "history", { workspace, name }))[0]; + const sourceBefore = await call(binding, original.libraryKey, "version", { workspace, id: version.version_id }); + await links("move", original, "team"); + const selection = { ...original, libraryKey: "team" }; + const generation = await links("begin", original); + await links("commit", original, generation, { slug: `trash-${kind}`, access: "public" }); + await expect(links("setTrashed", original, true)).rejects.toThrow("not found"); + expect(await links("setTrashed", selection, true)).toMatchObject({ trashed: true }); + expect(await links("catalog", { ...selection, method: "listDrafts" })).toEqual([]); + expect(await links("catalog", { ...selection, method: "history" })).toEqual([]); + await expect(links("admit", selection)).rejects.toThrow("not found"); + await expect(links("admit", { ...selection, name: undefined, version_id: version.version_id })).rejects.toThrow("not found"); + await expect(links("admit", selection, true)).rejects.toThrow("in Trash"); + await expect(links("move", selection, "trash-alice")).rejects.toThrow("not found"); + expect(await links("get", `trash-${kind}`)).toBeNull(); + expect(await links("find", original)).toBeNull(); + expect(await links("commit", original, generation, { slug: `trash-${kind}`, access: "public" })).toBeNull(); + await expect(links("check", { ...original, name: "another" }, `trash-${kind}`)).rejects.toThrow("already in use"); + expect(await links("trash", { libraryKey: "trash-alice" })).toEqual([]); + expect(await links("trash", { libraryKey: "team", workspace })).toMatchObject([{ workspace, kind, name }]); + await expect(links("setTrashed", original, false)).rejects.toThrow("not found"); + expect(await links("setTrashed", selection, false)).toMatchObject({ trashed: false }); + expect(await links("trash", { libraryKey: "team", workspace })).toEqual([]); + expect(await links("admit", selection)).toEqual(original); + expect(await call(binding, original.libraryKey, "version", { workspace, id: version.version_id })).toEqual(sourceBefore); + expect(await links("catalog", { ...selection, method: "history" })).toEqual([version]); + expect(await links("get", `trash-${kind}`)).toMatchObject({ access: "public", name }); + // A compile admitted before deletion cannot replace the restored link. + expect(await links("commit", original, generation, { slug: "late", access: "public" })).toBeNull(); + }); test(`${kind}: moves preserve physical source, projects, history, state/secrets and links; both directions enforce ownership`, async () => { const workspace = `preserve-${kind}`, name = "existing", original = { libraryKey: "alice", workspace, kind, name }; const source = "original source\n", project = { files: { "helper.ts": "export const value = 7;" }, dependencies: {}, lock: {} }; diff --git a/cloudflare/ownership.ts b/cloudflare/ownership.ts index f18edea..70b68c2 100644 --- a/cloudflare/ownership.ts +++ b/cloudflare/ownership.ts @@ -13,6 +13,7 @@ export type CatalogRow = { }; type OwnershipRow = { physical: string; owner: string; workspace: string; kind: string; name: string }; +export type TrashedItem = { workspace: string; kind: "artifact" | "script"; name: string; deleted_at: string }; const physicalKey = (target: ArtifactTarget) => JSON.stringify([target.libraryKey, target.workspace, target.kind, target.name]); const fromKey = (value: string): ArtifactTarget => { const [libraryKey, workspace, kind, name] = JSON.parse(value); @@ -42,6 +43,7 @@ export class LibraryOwnership { physical text primary key, owner text not null, workspace text not null, kind text not null, name text not null, unique(owner,workspace,kind,name) )`); + sql.exec("create table if not exists library_trash (physical text primary key, deleted_at text not null)"); } // Only admission and destination reservation are serialized. Source compilation, @@ -61,6 +63,10 @@ export class LibraryOwnership { return this.sql.exec<{ owner: string }>("select owner from library_ownership where physical=?", physicalKey(target)).toArray()[0]?.owner ?? target.libraryKey; } + isTrashed(target: ArtifactTarget): boolean { + return this.sql.exec("select 1 from library_trash where physical=?", physicalKey(target)).toArray().length !== 0; + } + private mapped(selection: LibrarySelection & { name: string }): ArtifactTarget | null { const row = this.sql.exec("select * from library_ownership where owner=? and workspace=? and kind=? and name=?", selection.libraryKey, selection.workspace, selection.kind, selection.name).toArray()[0]; if (row) return fromKey(row.physical); @@ -84,7 +90,7 @@ export class LibraryOwnership { return (await library.history({ workspace: target.workspace, name: target.name, limit: 1 })).length !== 0; } - private async resolve(selection: LibrarySelection): Promise { + private async resolve(selection: LibrarySelection, includeTrash = false): Promise { if (selection.kind !== "artifact" && selection.kind !== "script") throw new Error("kind must be artifact or script"); const name = selection.name === undefined ? undefined : ownershipName(selection.kind, selection.name); if (selection.version_id) { @@ -94,7 +100,7 @@ export class LibraryOwnership { try { version = await library.version({ workspace: selection.workspace, id: selection.version_id }); } catch (error) { if (!(error instanceof Error) || !error.message.includes("not found")) throw error; continue; } const target = { libraryKey, workspace: selection.workspace, kind: selection.kind, name: version.name }; - if (this.owner(target) !== selection.libraryKey) continue; + if (this.owner(target) !== selection.libraryKey || !includeTrash && this.isTrashed(target)) continue; if (name !== undefined && name !== target.name) throw new Error("Version does not belong to this item"); return target; } @@ -102,7 +108,7 @@ export class LibraryOwnership { } if (name === undefined) throw new Error("name or version_id is required"); const target = this.mapped({ ...selection, name }); - if (!target) throw new Error("item not found in this library"); + if (!target || !includeTrash && this.isTrashed(target)) throw new Error("item not found in this library"); return target; } @@ -115,11 +121,40 @@ export class LibraryOwnership { // item with that logical name gets fresh storage instead of overwriting it. libraryKey: `item:${crypto.randomUUID()}`, workspace: selection.workspace, kind: selection.kind, name, }; + if (this.isTrashed(target)) throw new Error("Item is in Trash; restore it before editing or choose a new name"); this.register(target, selection.libraryKey); return target; }); } + setTrashed(selection: LibrarySelection & { name: string }, trashed: boolean, suspend: (target: ArtifactTarget, trashed: boolean) => Promise) { + return this.serialized(async () => { + const target = await this.resolve(selection, true); + if (!await this.exists(target)) throw new Error("item not found in this library"); + this.register(target, selection.libraryKey); + if (trashed) { + // Revoke admission first. If suspension fails, retrying deletion repairs + // it without making the item accessible again. + this.sql.exec("insert or ignore into library_trash values(?,?)", physicalKey(target), new Date().toISOString()); + await suspend(target, true); + } else { + if (!this.isTrashed(target)) throw new Error("Item is not in Trash"); + await suspend(target, false); + this.sql.exec("delete from library_trash where physical=?", physicalKey(target)); + } + return target; + }); + } + + trash(input: { libraryKey: string; workspace?: string; offset?: number }): TrashedItem[] { + const offset = input.offset ?? 0; + if (!Number.isSafeInteger(offset) || offset < 0) throw new Error("invalid pagination"); + return this.sql.exec(`select o.workspace,o.kind,o.name,t.deleted_at from library_trash t + join library_ownership o on o.physical=t.physical + where o.owner=? and (? is null or o.workspace=?) + order by t.deleted_at desc,o.workspace,o.kind,o.name limit 100 offset ?`, input.libraryKey, input.workspace ?? null, input.workspace ?? null, offset).toArray(); + } + admitRemix(selection: LibrarySelection, newName: string): Promise { return this.serialized(async () => { const source = await this.resolve(selection); @@ -128,7 +163,7 @@ export class LibraryOwnership { const target = { ...source, name }; // Provenance references live in the source library. Keep the existing remix // transaction there, reserving its new logical identity before execution. - if ((logical && await this.exists(logical)) || await this.exists(target) || this.owner(target) !== source.libraryKey && this.owner(target) !== selection.libraryKey) throw new Error("Destination name is already in use; choose a new name"); + if ((logical && await this.exists(logical)) || this.isTrashed(target) || await this.exists(target) || this.owner(target) !== source.libraryKey && this.owner(target) !== selection.libraryKey) throw new Error("Destination name is already in use; choose a new name"); const claimed = this.sql.exec("select * from library_ownership where owner=? and workspace=? and kind=? and name=?", selection.libraryKey, selection.workspace, selection.kind, name).toArray()[0]; if (claimed && claimed.physical !== physicalKey(target)) throw new Error("Destination name is already in use; choose a new name"); this.register(target, selection.libraryKey); @@ -166,7 +201,8 @@ export class LibraryOwnership { for (const row of rows) { const rowName = input.method === "listDrafts" && input.kind === "artifact" ? row.id! : row.name; if (name !== undefined && rowName !== name) continue; - if (this.owner({ libraryKey, workspace: row.workspace, name: rowName, kind: input.kind }) !== input.libraryKey) continue; + const target = { libraryKey, workspace: row.workspace, name: rowName, kind: input.kind }; + if (this.owner(target) !== input.libraryKey || this.isTrashed(target)) continue; result.push(row as CatalogRow); accepted++; } if (rows.length < 100) break; diff --git a/cloudflare/script-backend.ts b/cloudflare/script-backend.ts index beb68ea..a20dc76 100644 --- a/cloudflare/script-backend.ts +++ b/cloudflare/script-backend.ts @@ -113,6 +113,7 @@ export class ScriptBackend extends DurableObject { return this.execute({...active, request}); } private async execute(input: ScriptRequest): Promise { + if (await this.ctx.storage.get("trashed")) throw new Error("Item is in Trash"); // The namespace identity is deliberately part of the loader key: identical // scripts owned by different libraries must never share secrets or globals. const hash=this.cacheKey(input); @@ -153,14 +154,22 @@ export class ScriptBackend extends DurableObject { if (!Number.isSafeInteger(limit)||limit<1||limit>100) throw new Error("limit must be between 1 and 100"); return this.ctx.storage.sql.exec("select * from execution_runs order by started_at desc,rowid desc limit ?",limit).toArray(); } + async setTrashed(trashed: boolean) { + if (trashed) await this.ctx.storage.put("trashed", true); + if (await this.schedule()) await this.schedule({ action: "pause" }); + await this.ctx.storage.deleteAlarm(); + if (!trashed) await this.ctx.storage.put("trashed", false); + } async schedule(input: {identity?: ScheduleIdentity; interval_seconds?: number; cron?: string; timezone?: string; request?: ArtifactHttpRequest; action?: "get"|"set"|"pause"|"resume"|"run_now"} = {}): Promise { const action=input.action??"get"; + if (action !== "get" && action !== "pause" && await this.ctx.storage.get("trashed")) throw new Error("Item is in Trash"); if (!["get","set","pause","resume","run_now"].includes(action)) throw new Error("Unknown schedule action"); if (action==="get") return await this.ctx.storage.get("schedule")??null; if (action==="run_now") { await this.runScheduled("manual"); return await this.schedule(); } let next: ScriptSchedule|null=null; await this.ctx.storage.transaction(async storage => { const previous=await storage.get("schedule"); + if (action !== "pause" && await storage.get("trashed")) throw new Error("Item is in Trash"); if (action!=="set" && !previous) throw new Error("No schedule configured"); if (action==="set") { const timing=normalizeTiming(input); @@ -176,6 +185,7 @@ export class ScriptBackend extends DurableObject { return next; } async alarm() { + if (await this.ctx.storage.get("trashed")) return; // Commit the next occurrence before invoking user code. Alarm redelivery // then sees a future timestamp and cannot replay this occurrence. let due: {id:string;started:number;schedule:ScriptSchedule}|undefined; diff --git a/cloudflare/script-guide.ts b/cloudflare/script-guide.ts index b375313..7d944ab 100644 --- a/cloudflare/script-guide.ts +++ b/cloudflare/script-guide.ts @@ -1,5 +1,9 @@ export const HOSTED_SCRIPT_GUIDE = `Hosted standalone scripts and root URLs +One MCP connection supports both personal and team libraries. Pass library_scope:"private" or library_scope:"team" on any tool call; omit it to use the connection URL's default. The selector always uses authenticated ownership and cannot select another user's personal library. In artifacts_query/artifacts_execute, put the selector inside the discovered operation's arguments. + +artifact_delete and script_delete move owned items to recoverable Trash. URLs stop working, file-transfer grants are revoked and schedules pause. Names and slugs stay reserved; history, data, files and secrets are preserved. artifacts_trash lists this library's Trash (all:true includes every workspace), and artifact_undelete/script_undelete restore an item. Restored schedules stay paused until explicitly resumed. Requests admitted before deletion may finish. + Artifacts and scripts coexist. Use artifact_write with an optional slug, or artifact_link({kind:"artifact",name,slug,access}), to give an artifact a self-contained root URL. Script writes choose slug explicitly or default to the script name. Root slugs are unique across this deployment, use lowercase letters/numbers/hyphens, and exclude application routes. No namespace or type prefix is added. Names and slugs are separate. Private is the default; access:"public" enables external callers. Management remains authenticated. Create arbitrary Workers-compatible TypeScript using script_write({name,slug,contents,access}). Export default {async fetch(request, env, ctx) { return new Response("Hello"); }}. A handler can return HTML, JSON, binary responses, or streams, receive webhooks, or call external APIs through fetch. Supported runtime bindings are env.secrets (explicitly configured per-script values) and env.sql (persistent native SQLite, with .exec and bound parameters). ScriptEnv and ExecutionContext types are available. No application credentials are implicitly supplied. Workers-supported cloudflare: and node: imports are available. Use project.files for relative helper modules and project.dependencies for exact npm versions. The entrypoint accepts at most 256 KiB UTF-8; helpers support 64 files and 1 MiB total. The service resolves dependencies when their declarations change and archives integrity-verified source and types. Source compilation and execution use the archived lock without downloading dependencies. diff --git a/cloudflare/tool-contract.ts b/cloudflare/tool-contract.ts index 4c43777..fcc1e25 100644 --- a/cloudflare/tool-contract.ts +++ b/cloudflare/tool-contract.ts @@ -164,7 +164,22 @@ addScriptTool("app_secrets", "Atomically set/delete app secrets (null deletes); addScriptTool("app_move", "Move an app between authenticated private/team libraries without changing its physical app identity, resources, secrets or revisions. The former owner immediately loses future management and HTTP access.", { ...appName, library: { type: "string", enum: ["private", "team"] } }, ["name", "library"]); for (const tool of CLOUD_MCP_TOOLS.filter(tool => ["app_guide", "app_list", "app_read", "app_history"].includes(tool.name))) tool.annotations = { readOnlyHint: true }; -for (const tool of [...ARTIFACTS_WORKSPACE_TOOLS, ARTIFACTS_FILE_TOOL]) if (!CLOUD_MCP_TOOLS.some(existing => existing.name === tool.name)) CLOUD_MCP_TOOLS.push(tool as Tool); +for (const kind of ["artifact", "script"]) { + for (const action of ["delete", "undelete"]) { + addScriptTool(`${kind}_${action}`, action === "delete" + ? "Move an owned item to recoverable Trash. Disables its URL and pauses schedules. Preserves source history, state, data, files and secrets. The name and URL stay reserved. In-flight executions may finish." + : "Restore an item from Trash to its library with its original URL, history, data, files and secrets. Schedules remain paused until explicitly resumed.", { + name: { type: "string", minLength: 1 }, workspace: { type: "string", minLength: 1 }, + }, ["name"]); + CLOUD_MCP_TOOLS[CLOUD_MCP_TOOLS.length - 1]!.annotations = { readOnlyHint: false, destructiveHint: false, openWorldHint: false }; + } +} +addScriptTool("artifacts_trash", "List recoverable Trash in the selected library. Only the authenticated personal library or accessible team library is visible. Paginated, 100 entries per response.", { + ...pageProperty, all: { type: "boolean", default: false, description: "Include every workspace in this library." }, +}); +CLOUD_MCP_TOOLS[CLOUD_MCP_TOOLS.length - 1]!.annotations = { readOnlyHint: true }; + +for (const tool of [...ARTIFACTS_WORKSPACE_TOOLS, ARTIFACTS_FILE_TOOL]) if (!CLOUD_MCP_TOOLS.some(existing => existing.name === tool.name)) CLOUD_MCP_TOOLS.push(structuredClone(tool) as Tool); for (const tool of CLOUD_MCP_TOOLS) { if (["artifact_write", "artifact_edit", "artifact_import", "artifact_remix", "artifact_restore"].includes(tool.name)) tool.inputSchema.properties!.preview = { type: "boolean", default: true, description: "Deliver the preview after saving and activating a valid revision. False suppresses delivery only; it does not roll back the mutation." }; if (["artifact_request", "artifact_files", "artifact_read", "artifact_export"].includes(tool.name)) tool.inputSchema.properties!.workspace = { type: "string", minLength: 1, description: "Workspace in the authenticated library. Does not grant access." }; @@ -173,5 +188,15 @@ for (const tool of CLOUD_MCP_TOOLS) { } applyToolVisibility(CLOUD_MCP_TOOLS); CLOUD_MCP_TOOLS.push(galleryToolDefinition(CLOUD_MCP_TOOLS)); -CLOUD_MCP_TOOLS.push(...ARTIFACTS_SETTINGS_TOOLS); -CLOUD_MCP_TOOLS.push(...DISCOVERY_TOOLS); +CLOUD_MCP_TOOLS.push(...structuredClone(ARTIFACTS_SETTINGS_TOOLS)); +CLOUD_MCP_TOOLS.push(...structuredClone(DISCOVERY_TOOLS)); + +// Select a library for this call, never by changing shared connection state. +// Clone schemas borrowed from local tools before adding the hosted selector. +for (const tool of CLOUD_MCP_TOOLS) { + tool.inputSchema = structuredClone(tool.inputSchema); + tool.inputSchema.properties = { ...tool.inputSchema.properties, library_scope: { + type: "string", enum: ["private", "team"], + description: "Personal (private) or shared team library for this call. One MCP connection supports both. Omit to use the connection URL's default. Never grants access to another user's personal library.", + } }; +} diff --git a/cloudflare/worker.test.ts b/cloudflare/worker.test.ts index 21b391b..afd1ba0 100644 --- a/cloudflare/worker.test.ts +++ b/cloudflare/worker.test.ts @@ -117,6 +117,87 @@ test("hosted conditional saves return conflicts and invalid drafts can be correc expect((await write(good, null)).response.status).toBe(409); }); +test("gallery deletion moves an artifact to recoverable Trash, revokes files and preserves data with paused schedules", async () => { + const name = "trash-roundtrip"; + const call = async (tool: string, args: Record = {}) => { + const result = await client.callTool({ name: tool, arguments: { name, ...args } }); + expect(result.isError, JSON.stringify(result.content)).not.toBe(true); + return result.structuredContent ?? payload(result); + }; + const written = await call("artifact_write", { contents: counterClient, server: counterServer, access: "public", slug: name }); + const versionId = written.preview.artifact.versionId; + await call("artifact_secrets", { secrets: { RETAINED: "kept" } }); + const counter = async (method = "GET") => { + const result = await call("artifact_request", { request: { path: "/counter", method } }); + return JSON.parse(atob(result.response.body)); + }; + expect((await counter("POST")).value).toBe(1); + await call("artifact_schedule", { action: "set", interval_seconds: 3600, request: { path: "/counter", method: "POST" } }); + const upload = (await call("artifact_files", { request: { operation: "upload", name: "kept.txt", size: 4, type: "text/plain" } })).result; + expect((await fetch(upload.url, { method: "PUT", body: "kept" })).status).toBe(201); + const download = (await call("artifact_files", { request: { operation: "download", id: upload.file.id } })).result; + const browser = await chromium.launch({ headless: true }); + try { + const page = await browser.newPage(); + await page.goto(`${origin}/?workspace=test&name=${name}`); + await page.getByRole("heading", { name, exact: true }).waitFor(); + await page.getByRole("button", { name: "More actions", exact: true }).click(); + await page.getByRole("menuitem", { name: "Delete", exact: true }).click(); + const panel = page.getByRole("form", { name: "Move to Trash", exact: true }); + await panel.getByRole("button", { name: "Cancel", exact: true }).click(); + expect((await fetch(`${origin}/${name}`)).status).toBe(200); + await page.getByRole("button", { name: "More actions", exact: true }).click(); + await page.getByRole("menuitem", { name: "Delete", exact: true }).click(); + await panel.getByRole("button", { name: "Move to Trash", exact: true }).click(); + await page.getByRole("button", { name, exact: true }).waitFor({ state: "detached" }); + expect((await fetch(`${origin}/${name}`)).status).toBe(404); + expect((await fetch(download.url)).status).toBe(404); + expect((await client.callTool({ name: "artifact_read", arguments: { name } })).isError).toBe(true); + expect((await client.callTool({ name: "artifact_open", arguments: { version_id: versionId } })).isError).toBe(true); + expect((await client.callTool({ name: "artifact_write", arguments: { name, contents: source } })).isError).toBe(true); + await page.getByRole("button", { name: "Trash", exact: true }).click(); + const trash = page.getByRole("region", { name: "Trash", exact: true }); + await trash.getByRole("button", { name: `Restore ${name}`, exact: true }).waitFor(); + await page.setViewportSize({ width: 390, height: 844 }); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true); + if (process.env.GALLERY_SCREENSHOT_DIR) await page.screenshot({ path: `${process.env.GALLERY_SCREENSHOT_DIR}/trash.png` }); + await trash.getByRole("button", { name: `Restore ${name}`, exact: true }).click(); + await trash.getByText("Trash is empty.", { exact: true }).waitFor(); + await trash.getByRole("button", { name: "Back to library", exact: true }).click(); + await page.getByRole("button", { name, exact: true }).waitFor(); + } finally { await browser.close(); } + expect((await counter()).value).toBe(1); + expect((await call("artifact_schedule")).schedule).toMatchObject({ paused: true, next_run_at: null }); + expect((await call("artifact_secrets")).names).toEqual(["RETAINED"]); + expect((await call("artifact_files", { request: { operation: "list" } })).result.files).toMatchObject([{ id: upload.file.id }]); + const restoredDownload = (await call("artifact_files", { request: { operation: "download", id: upload.file.id } })).result; + expect(await (await fetch(restoredDownload.url)).text()).toBe("kept"); + expect(payload(await client.callTool({ name: "artifact_version", arguments: { version_id: versionId } })).source).toBe(counterClient); + expect((await fetch(`${origin}/${name}`)).status).toBe(200); +}, 60_000); + +test("one MCP connection trashes and restores team scripts while keeping schedules paused", async () => { + const name = "team-trash-handler", library_scope = "team"; + const call = async (tool: string, args: Record = {}) => { + const result = await client.callTool({ name: tool, arguments: { name, library_scope, ...args } }); + expect(result.isError, JSON.stringify(result.content)).not.toBe(true); + return payload(result); + }; + await call("script_write", { contents: 'export default { fetch() { return new Response("retained"); } };' }); + await call("script_secrets", { secrets: { RETAINED: "kept" } }); + await call("script_schedule", { action: "set", interval_seconds: 3600 }); + await call("script_delete"); + expect((await fetch(`${origin}/${name}`)).status).toBe(404); + expect((await client.callTool({ name: "script_run", arguments: { name, library_scope, request: { path: "/" } } })).isError).toBe(true); + const trash = payload(await client.callTool({ name: "artifacts_query", arguments: { operation: "artifacts_trash", arguments: { library_scope } } })); + expect(trash.items).toMatchObject([{ name, kind: "script" }]); + expect(payload(await client.callTool({ name: "artifacts_trash", arguments: {} })).items).toEqual([]); + await call("script_undelete"); + expect(await (await fetch(`${origin}/${name}`)).text()).toBe("retained"); + expect((await call("script_schedule")).schedule).toMatchObject({ paused: true, next_run_at: null }); + expect((await call("script_secrets")).names).toEqual(["RETAINED"]); +}); + test("official HTTP MCP client lists contracts, writes, edits, restores and retrieves raw history", async () => { const tools = (await client.listTools()).tools; expect(tools.some(tool => tool.name === "artifact_write")).toBe(true); @@ -375,6 +456,24 @@ test("verified users have isolated private libraries and can collaborate in the expect((await teamBob.callTool({ name: "artifact_edit", arguments: { name: "shared", edits: [{ old_text: "Hosted artifact", new_text: "Team artifact" }] } })).isError).not.toBe(true); expect(payload(await teamAlice.callTool({ name: "artifact_read", arguments: { name: "shared" } })).source).toContain("Team artifact"); expect((await alice.callTool({ name: "artifact_read", arguments: { name: "shared" } })).isError).toBe(true); + // One authenticated connection can select either library independently on + // concurrent calls. A team URL remains a compatible default, not a boundary. + const [personalRead, teamRead] = await Promise.all([ + alice.callTool({ name: "artifact_read", arguments: { name: "secret", library_scope: "private" } }), + alice.callTool({ name: "artifact_read", arguments: { name: "shared", library_scope: "team" } }), + ]); + expect(payload(personalRead).source).toBe(source); + expect(payload(teamRead).source).toContain("Team artifact"); + expect(payload(await teamAlice.callTool({ name: "artifact_read", arguments: { name: "secret", library_scope: "private" } })).source).toBe(source); + expect((await teamBob.callTool({ name: "artifact_read", arguments: { name: "secret", library_scope: "private" } })).isError).toBe(true); + const mentions = await alice.callTool({ name: "artifacts_mentions", arguments: { query: "shared", library_scope: "team" } }); + const mention = (mentions.content as { uri: string }[])[0]!; + expect(mention.uri).toContain("library_scope=team"); + const mentioned = await alice.readResource({ uri: mention.uri }); + expect(JSON.parse((mentioned.contents[0] as { text: string }).text).source).toContain("Team artifact"); + const scopedPreview = await alice.callTool({ name: "artifact_open", arguments: { name: "shared", library_scope: "team" } }); + expect(scopedPreview._meta?.artifact).toMatchObject({ library_scope: "team" }); + expect((await bob.callTool({ name: "artifact_delete", arguments: { name: "secret", library_scope: "private" } })).isError).toBe(true); for (const path of [`/api/source?version=${privateVersion}`, `/gallery/preview?version=${privateVersion}`, `/api/source?name=secret&subject=alice`, `/api/source?library=team&version=${privateVersion}`]) { expect((await fetch(address + path + "&workspace=test", { headers: { "Cf-Access-Jwt-Assertion": bobToken } })).status).toBe(404); } @@ -969,7 +1068,13 @@ test("MCP workspace tools admit cross-workspace previews and resources while sup const uri = (mentions.structuredContent as any).items[0].uri; const resource = await client.readResource({ uri }); expect(JSON.parse((resource.contents[0] as { text: string }).text)).toMatchObject({ workspace: "workspace-tools", name: "workspace-preview", source }); - await expect(team.readResource({ uri })).rejects.toThrow(); + // A resource carries its library selector. The same person's connection + // can read it even when that connection defaults to the team library. + const fromTeamConnection = await team.readResource({ uri }); + expect(JSON.parse((fromTeamConnection.contents[0] as { text: string }).text)).toMatchObject({ source }); + const teamUri = new URL(uri); + teamUri.searchParams.set("library_scope", "team"); + await expect(team.readResource({ uri: teamUri.href })).rejects.toThrow(); expect((await team.callTool({ name: "artifacts_preview", arguments: { workspace: "workspace-tools", version_id: versionId } })).isError).toBe(true); const opened = await client.callTool({ name: "artifacts_preview", arguments: { workspace: "workspace-tools", version_id: versionId } }); expect(opened.isError).not.toBe(true); @@ -1013,7 +1118,11 @@ test("MCP gallery editor keeps full projects, workspace routing, CAS errors and const team = new Client({ name: "gallery-denied", version: "1" }); await team.connect(new StreamableHTTPClientTransport(new URL(`${origin}/mcp?workspace=${workspace}&library=team`), { fetch })); try { - await expect(team.readResource({ uri: scriptUri })).rejects.toThrow(); + const fromTeamConnection = await team.readResource({ uri: scriptUri }); + expect(JSON.parse((fromTeamConnection.contents[0] as { text: string }).text)).toMatchObject({ source: script }); + const teamUri = new URL(scriptUri); + teamUri.searchParams.set("library_scope", "team"); + await expect(team.readResource({ uri: teamUri.href })).rejects.toThrow(); expect((await team.callTool({ name: "artifacts_source", arguments: { workspace, kind: "script", name } })).isError).toBe(true); const deniedCatalog = await team.callTool({ name: "artifacts_library", arguments: {} }); expect((deniedCatalog._meta as any).workspace.gallery.artifacts.some((item: { name: string }) => item.name === name)).toBe(false); diff --git a/cloudflare/workspace-tools.ts b/cloudflare/workspace-tools.ts index eb946c7..726ff21 100644 --- a/cloudflare/workspace-tools.ts +++ b/cloudflare/workspace-tools.ts @@ -20,7 +20,7 @@ export async function workspaceTool(service: { workspace: string; productUrl?: s page = await service.gallery(true, page.nextOffset); matches.push(...page.artifacts.filter(item => item.name.toLocaleLowerCase().includes(query))); } - const links = matches.slice(0, 100).map(item => ({ type: "resource_link" as const, uri: artifactResourceUri(item, item.working ? undefined : item.versions[0]?.id), name: item.name, title: `${item.name} · ${item.kind ?? "artifact"} · ${item.workspace}`, mimeType: "application/json", description: `${item.kind === "script" ? "Script" : "React artifact"} project and revision in workspace ${item.workspace}` })); + const links = matches.slice(0, 100).map(item => ({ type: "resource_link" as const, uri: artifactResourceUri(item, item.working ? undefined : item.versions[0]?.id, gallery.libraryScope), name: item.name, title: `${item.name} · ${item.kind ?? "artifact"} · ${item.workspace}`, mimeType: "application/json", description: `${item.kind === "script" ? "Script" : "React artifact"} project and revision in workspace ${item.workspace}` })); return { content: links, structuredContent: { items: links } }; } return workspaceResult({ view, workspace: gallery.workspace, items, nextOffset: gallery.nextOffset ?? null, gallery: { ...gallery, artifacts: items }, ...(service.productUrl ? { productUrl: service.productUrl } : {}) }); diff --git a/docs/authentication.md b/docs/authentication.md index 5f0644d..6f2035d 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -40,15 +40,18 @@ credentials. The default local host needs no sign-in. and sign in with one of its configured providers. The deployment administrator must admit your account. Choose **My library** or **Team library** in the gallery. -**Coding agent:** add a remote HTTP MCP server using one of these URLs: +**Coding agent:** add one remote HTTP MCP server: ```text https://artifacts.example.com/mcp -https://artifacts.example.com/mcp?library=team&workspace=default ``` -The first selects your personal library and the default workspace. The second -selects the shared team library. Use the same hostname as the gallery. Your MCP +The connection can use both libraries. Select **Personal library** or **Team +library** in the MCP gallery, or pass `library_scope: "private"` or +`library_scope: "team"` on tool calls. The selector is independent for each call; +it never selects another person's personal library. Existing URLs such as +`/mcp?library=team&workspace=default` remain supported as defaults when a call +omits the selector. Use the same hostname as the gallery. Your MCP client must support OAuth: connect, complete provider sign-in in the browser, and approve access when prompted. Client registration is handled by the OAuth flow; you do not need to supply a client ID or secret for a supported public client. diff --git a/docs/cloudflare.md b/docs/cloudflare.md index e153bbb..32d9805 100644 --- a/docs/cloudflare.md +++ b/docs/cloudflare.md @@ -79,8 +79,9 @@ application and user policy, enable managed OAuth for MCP, and configure ### Connect users and agents Open the deployed hostname for the gallery. Add `https:///mcp` -to an OAuth-capable MCP client for a personal library, or append -`?library=team&workspace=` for the shared team library. See +to an OAuth-capable MCP client. One connection supports both libraries: select +one in the MCP gallery or use `library_scope: "private"` / `"team"` on tool calls. +Existing `?library=team&workspace=` URLs remain supported as defaults. See [connection instructions and supported credentials](authentication.md#connect-to-an-existing-deployment). Before switching modes on an existing deployment, read [the identity migration note](authentication.md#changing-authentication-modes-and-upgrading). diff --git a/e2e/gallery-onboarding.test.ts b/e2e/gallery-onboarding.test.ts index 2e4bf45..666c882 100644 --- a/e2e/gallery-onboarding.test.ts +++ b/e2e/gallery-onboarding.test.ts @@ -18,7 +18,7 @@ test("empty hosted library supplies copyable deployment MCP URL and starter prom const page = await context.newPage(); await page.goto(new URL("?library=team", server.url).href); const url = await page.getByLabel("Deployment MCP URL").inputValue(); - expect(url).toBe(new URL("/mcp?workspace=my-project&library=team", server.url).href); + expect(url).toBe(new URL("/mcp?workspace=my-project", server.url).href); const before = await page.locator(".agent-onboarding").boundingBox(); await page.getByRole("button", { name: "Copy MCP URL", exact: true }).click(); await page.getByText("MCP URL copied", { exact: true }).waitFor(); @@ -26,6 +26,7 @@ test("empty hosted library supplies copyable deployment MCP URL and starter prom const prompt = await page.getByLabel("Starter prompt", { exact: true }).inputValue(); expect(prompt).toContain("artifact_guide"); expect(prompt).toContain("private access"); + expect(prompt).toContain('library_scope: "team"'); await page.getByRole("button", { name: "Copy starter prompt", exact: true }).click(); await page.getByText("Starter prompt copied", { exact: true }).waitFor(); expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(prompt); diff --git a/e2e/mcp-app.test.ts b/e2e/mcp-app.test.ts index 29618dc..3eb3570 100644 --- a/e2e/mcp-app.test.ts +++ b/e2e/mcp-app.test.ts @@ -793,6 +793,55 @@ test("workspace keeps search and pinned revisions while its sandboxed preview re expect(errors).toEqual([]); }, 30_000); +test("one MCP gallery switches libraries and scopes source and Trash actions without another connection", async () => { + const meta = await resultFor(INTERACTIVE_ARTIFACT, "scoped"); + meta.artifact.workspace = "research"; + const payload = (scope: "private" | "team", empty = false) => ({ + ...workspaceFor(meta.artifact), gallery: { + workspace: "research", libraryScope: scope, nextOffset: null, + capabilities: { editing: true, links: true, scripts: true, trash: true }, + artifacts: empty ? [] : [{ name: "scoped", key: "scoped", workspace: "research", library_scope: scope, working: true, + versions: [{ id: meta.artifact.versionId, revision: 1, createdAt: "2026-11-02T00:00:00Z", reason: "save", serveCount: 0 }] }], + }, + }); + const personal = payload("private"), team = payload("team"), emptyTeam = payload("team", true); + const { page, app, errors } = await openHost({ proxy: true }); + await page.evaluate(({ meta, personal }) => { + window.mcpHost!.configure({ displayMode: "fullscreen" }); + window.mcpHost!.setServerToolResults({ + artifacts_search: [{ content: [], _meta: { workspace: personal } }], + artifacts_preview: [{ content: [], _meta: { artifact: { ...meta.artifact, library_scope: "private" } } }], + }); + }, { meta, personal }); + await page.evaluate(workspace => window.mcpHost!.sendResult({ content: [], _meta: { workspace } }), personal); + await app.frameLocator(".preview-frame").getByRole("button", { name: "Increment" }).click(); + await app.frameLocator(".preview-frame").getByText("Count: 1", { exact: true }).waitFor(); + await page.waitForFunction(() => window.mcpHost!.serverToolCalls.some(call => call.name === "artifacts_search")); + await page.evaluate(({ meta, team }) => window.mcpHost!.setServerToolResults({ + artifacts_search: [{ content: [], _meta: { workspace: team } }], + artifacts_preview: [{ content: [], _meta: { artifact: { ...meta.artifact, library_scope: "team" } } }], + artifacts_source: [{ content: [], structuredContent: { source: "export default () => null", project: { files: {}, dependencies: {}, lock: {} }, revision_token: "a".repeat(64) } }], + artifacts_tool: [{ content: [], structuredContent: { ok: true } }], + }), { meta, team }); + await app.getByRole("combobox", { name: "Library", exact: true }).selectOption("team"); + await app.frameLocator(".preview-frame").getByText("Count: 0", { exact: true }).waitFor(); + await app.getByRole("tab", { name: "Source", exact: true }).click(); + await app.getByRole("textbox", { name: "scoped.artifact.tsx", exact: true }).waitFor(); + await page.evaluate(emptyTeam => window.mcpHost!.setServerToolResults({ + artifacts_search: [{ content: [], _meta: { workspace: emptyTeam } }], + artifacts_tool: [{ content: [], structuredContent: { ok: true } }], + }), emptyTeam); + await app.getByRole("button", { name: "More actions", exact: true }).click(); + await app.getByRole("menuitem", { name: "Delete", exact: true }).click(); + await app.getByRole("button", { name: "Move to Trash", exact: true }).click(); + await app.getByRole("button", { name: "scoped", exact: true }).waitFor({ state: "detached" }); + const calls = await page.evaluate(() => window.mcpHost!.serverToolCalls); + expect(calls.find(call => call.name === "artifacts_source")?.arguments).toMatchObject({ library_scope: "team", workspace: "research", name: "scoped" }); + expect(calls.find(call => call.name === "artifacts_tool")?.arguments).toEqual({ library_scope: "team", workspace: "research", tool: "artifact_delete", arguments: { name: "scoped" } }); + expect(calls.filter(call => call.name === "artifacts_preview").map(call => call.arguments?.library_scope)).toContain("team"); + expect(errors).toEqual([]); +}, 30_000); + test("OpenAI deep links select a revision through the server before entering its internal route", async () => { const { ROUTING_ARTIFACT } = await import("../src/test/routing"); const meta = await resultFor(ROUTING_ARTIFACT, "linked"); diff --git a/package.json b/package.json index 35975ff..0269b3d 100644 --- a/package.json +++ b/package.json @@ -126,6 +126,7 @@ "src/gallery/source-editor.tsx", "src/gallery/select.tsx", "src/gallery/move.tsx", + "src/gallery/trash.tsx", "src/gallery/theme.ts", "src/gallery/brand.ts", "src/gallery/Geist-OFL.txt", diff --git a/src/gallery/client.tsx b/src/gallery/client.tsx index 0c19e75..84fe29f 100644 --- a/src/gallery/client.tsx +++ b/src/gallery/client.tsx @@ -1,6 +1,7 @@ import { Tabs } from "@base-ui/react/tabs"; import { Menu } from "@base-ui/react/menu"; import { LibraryNavigation } from "./library-navigation"; +import { DeletePanel, TrashPanel } from "./trash"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { createRoot } from "react-dom/client"; import { authClient, signInUrl } from "../auth/client-api"; @@ -117,6 +118,8 @@ function App() { const [kindFilter, setKindFilter] = useState("all"); const [showLinks, setShowLinks] = useState(false); const [showMove, setShowMove] = useState(false); + const [showDelete, setShowDelete] = useState(false); + const [showTrash, setShowTrash] = useState(false); const [showImport, setShowImport] = useState(false); const [notice, setNotice] = useState(""); const [sourceVisited, setSourceVisited] = useState(null); @@ -408,6 +411,7 @@ function App() { setMobileDetail(true); setShowLinks(false); setShowMove(false); + setShowDelete(false); setCreatingKind(null); setShowImport(false); setNotice(""); @@ -447,6 +451,7 @@ function App() { setRemixing(false); setShowLinks(false); setShowMove(false); + setShowDelete(false); setMobileDetail(true); } async function createdItem(name: string, kind: "artifact" | "script") { @@ -555,6 +560,10 @@ function App() { {gallery?.workspace ? {gallery.workspace.split(/[\\/]/).filter(Boolean).at(-1) ?? gallery.workspace} : null}
+ {gallery?.capabilities?.trash ? : null} {gallery?.libraryScope ? event.target.select()} /> diff --git a/src/gallery/styles.ts b/src/gallery/styles.ts index 7808ac8..143e858 100644 --- a/src/gallery/styles.ts +++ b/src/gallery/styles.ts @@ -122,6 +122,11 @@ export const galleryStyles = ` .source-form > .script-fields { padding: 12px 16px; border-bottom: 1px solid var(--line); flex-shrink: 0; } .source-actions { display: flex; align-items: center; gap: 8px; padding: 10px 16px; border-top: 1px solid var(--line); flex-shrink: 0; } .source-actions p { margin: 0; color: var(--muted); } + .trash-panel { overflow: auto; } + .trash-panel > p { margin: 12px 16px; } + .trash-item { flex-wrap: wrap; } + .trash-item > span { flex: 1; min-width: 0; overflow-wrap: anywhere; } + .trash-item > button { flex-shrink: 0; } .gallery-app button.primary-action { background: var(--primary); color: var(--primary-text); border-color: var(--primary); font-weight: 600; } button.primary-action:disabled { opacity: .45; } /* Content actions are outlined; header, tab, and menu controls stay quiet. */ diff --git a/src/gallery/trash.tsx b/src/gallery/trash.tsx new file mode 100644 index 0000000..24fb1a5 --- /dev/null +++ b/src/gallery/trash.tsx @@ -0,0 +1,80 @@ +import { useCallback, useEffect, useState } from "react"; +import { confirmLeavingDrafts, useDrafts } from "./drafts"; +import { useGalleryTransport } from "./transport"; +import type { GalleryArtifact } from "./types"; + +type TrashedItem = { workspace: string; kind: "artifact" | "script"; name: string; deleted_at: string }; +type TrashPage = { items: TrashedItem[]; next_offset: number | null }; +function value(result: unknown): T { return (typeof result === "string" ? JSON.parse(result) : result) as T; } + +export function DeletePanel({ artifact, onCancel, onDeleted }: { + artifact: GalleryArtifact; onCancel: () => void; onDeleted: () => Promise; +}) { + const { tool } = useGalleryTransport(); + const { drafts } = useDrafts(); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + return
{ + event.preventDefault(); + if (busy || !confirmLeavingDrafts(drafts)) return; + setBusy(true); setError(""); + try { + await tool(artifact.workspace, `${artifact.kind ?? "artifact"}_delete`, { name: artifact.name }); + await onDeleted(); + } catch (error) { setError(error instanceof Error ? error.message : String(error)); } + finally { setBusy(false); } + }}> +

Move {artifact.name} to Trash?

+

Its link will stop working and schedules will pause. History, data, files and secrets are kept. You can restore it from Trash. Requests already running may finish.

+
+ {error ?

{error}

: null} +
; +} + +export function TrashPanel({ workspace, onRestored, onClose }: { + workspace: string; onRestored: () => Promise; onClose: () => void; +}) { + const { tool } = useGalleryTransport(); + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(true); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + const load = useCallback(async () => { + let offset = 0; + const items: TrashedItem[] = []; + do { + const page = value(await tool(workspace, "artifacts_trash", { all: true, offset })); + items.push(...page.items); + if (page.next_offset === null) break; + if (!Number.isSafeInteger(page.next_offset) || page.next_offset <= offset) throw new Error("Invalid Trash pagination"); + offset = page.next_offset; + } while (true); + return items; + }, [workspace, tool]); + useEffect(() => { + let cancelled = false; + void load().then(items => { if (!cancelled) setItems(items); }).catch(error => { + if (!cancelled) setError(error instanceof Error ? error.message : String(error)); + }).finally(() => { if (!cancelled) setLoading(false); }); + return () => { cancelled = true; }; + }, [load]); + return
+

Trash

+

Restore an item with its original link, history and stored data. Schedules stay paused until you resume them. Names and links are reserved while in Trash.

+ {loading ?

Loading Trash…

: null} + {error ?

{error}

: null} + {!loading && !error && items.length === 0 ?

Trash is empty.

: null} + {items.map(item =>
+ {item.name} · {item.kind} · {item.workspace} + +
)} +
; +} diff --git a/src/gallery/types.ts b/src/gallery/types.ts index 0386c5c..a151f49 100644 --- a/src/gallery/types.ts +++ b/src/gallery/types.ts @@ -1,5 +1,5 @@ export type GalleryVersion = { id: string; revision: number; createdAt: string; reason: string; serveCount: number }; -export type GalleryArtifact = { key: string; name: string; workspace: string; working: boolean; versions: GalleryVersion[]; kind?: "artifact" | "script"; slug?: string; url?: string; access?: "private" | "public"; live?: { id: string; revision: number; revision_token: string }; liveId?: string; draftRevision?: string }; +export type GalleryArtifact = { key: string; name: string; workspace: string; library_scope?: "private" | "team"; working: boolean; versions: GalleryVersion[]; kind?: "artifact" | "script"; slug?: string; url?: string; access?: "private" | "public"; live?: { id: string; revision: number; revision_token: string }; liveId?: string; draftRevision?: string }; export type GalleryWorker = { key: string; kind: "worker"; name: string; workspace: string; provider: string; status: string; revision_token: string }; export type GalleryItem = GalleryArtifact | GalleryWorker; -export type GalleryData = { workspace: string; artifacts: GalleryArtifact[]; workerApps?: GalleryWorker[]; nativeAppProviders?: string[]; libraryScope?: "private" | "team"; nextOffset?: number | null; capabilities?: { scripts?: boolean; links?: boolean; moves?: boolean; nativeApps?: boolean; subscriptions?: boolean; subscriptionTransport?: "events"; editing?: boolean } }; +export type GalleryData = { workspace: string; artifacts: GalleryArtifact[]; workerApps?: GalleryWorker[]; nativeAppProviders?: string[]; libraryScope?: "private" | "team"; nextOffset?: number | null; capabilities?: { scripts?: boolean; links?: boolean; moves?: boolean; trash?: boolean; nativeApps?: boolean; subscriptions?: boolean; subscriptionTransport?: "events"; editing?: boolean } }; diff --git a/src/gallery/workspace.tsx b/src/gallery/workspace.tsx index 27c0d4a..b443799 100644 --- a/src/gallery/workspace.tsx +++ b/src/gallery/workspace.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Tabs } from "@base-ui/react/tabs"; import { Menu } from "@base-ui/react/menu"; -import { DraftProvider, useDrafts, type SourceSnapshot } from "./drafts"; +import { DraftProvider, useDrafts, confirmLeavingDrafts, type SourceSnapshot } from "./drafts"; import { ArtifactSourcePanel, LinkSettings, ScriptPanel, type ScriptView } from "./hosted"; import { LibraryNavigation, type LibraryKindFilter } from "./library-navigation"; import { ProjectEditor } from "./project-editor"; @@ -10,8 +10,9 @@ import { Select } from "./select"; import { galleryStyles } from "./styles"; import { GalleryTransportProvider, useGalleryTransport, type GalleryTransport } from "./transport"; import type { GalleryArtifact, GalleryData } from "./types"; +import { DeletePanel, TrashPanel } from "./trash"; -export type WorkspaceSelection = { name?: string; workspace?: string; kind?: "artifact" | "script"; version_id?: string; route?: string; tab?: "preview" | "source" }; +export type WorkspaceSelection = { name?: string; workspace?: string; library_scope?: "private" | "team"; kind?: "artifact" | "script"; version_id?: string; route?: string; tab?: "preview" | "source" }; export type GalleryWorkspaceProps = { initial: GalleryData; view: "library" | "working"; @@ -22,10 +23,11 @@ export type GalleryWorkspaceProps = { attachedVersionId?: string; selection?: WorkspaceSelection; onSelectionChange?: (selection: WorkspaceSelection) => void; - openProduct?: () => Promise; + openProduct?: (scope?: "private" | "team") => Promise; openLink?: (url: string) => Promise; askCreate?: () => Promise; refreshKey?: number; + onLibraryChange?: (scope: "private" | "team") => Promise; }; /** Catalog pages may contain different revisions of the same project. */ @@ -128,7 +130,7 @@ export function GalleryWorkspace(props: GalleryWorkspaceProps) { return ; } -function Workspace({ initial, view, search, renderPreview, attach, attachedVersionId, selection, onSelectionChange, openProduct, openLink, askCreate, refreshKey }: GalleryWorkspaceProps) { +function Workspace({ initial, view, search, renderPreview, attach, attachedVersionId, selection, onSelectionChange, openProduct, openLink, askCreate, refreshKey, onLibraryChange }: GalleryWorkspaceProps) { const { drafts } = useDrafts(); const initialSelection = selectedGalleryArtifact(initial.artifacts, selection); const [gallery, setGallery] = useState(initial); @@ -149,7 +151,8 @@ function Workspace({ initial, view, search, renderPreview, attach, attachedVersi const [notice, setNotice] = useState(""); const [attaching, setAttaching] = useState(false); const [refresh, setRefresh] = useState(0); - const [disclosure, setDisclosure] = useState<"share" | "remix" | null>(null); + const [disclosure, setDisclosure] = useState<"share" | "remix" | "delete" | null>(null); + const [showTrash, setShowTrash] = useState(false); const [selectionLookup, setSelectionLookup] = useState<{ key: string; state: "loading" | "missing" | "error" }>(); const searchGeneration = useRef(0); const lookupGeneration = useRef(0); @@ -210,7 +213,7 @@ function Workspace({ initial, view, search, renderPreview, attach, attachedVersi const page = await readWorkspaceCatalog(search, searchQuery, () => generation === searchGeneration.current); if (!page) return; setGallery(page); - setCatalog(previous => mergeGalleryItems(previous, page.artifacts)); + setCatalog(previous => searchQuery ? mergeGalleryItems(previous, page.artifacts) : page.artifacts); return page; } catch (error) { if (generation === searchGeneration.current) setError(error instanceof Error ? error.message : String(error)); @@ -240,7 +243,7 @@ function Workspace({ initial, view, search, renderPreview, attach, attachedVersi useEffect(() => { if (activeTab === "source") setSourceVisited(detailId); }, [activeTab, detailId]); useEffect(() => { if (mobileDetail) headingRef.current?.focus(); }, [mobileDetail]); const notifySelection = (item: GalleryArtifact, selectedVersion: string, selectedRoute = "/", selectedTab = activeTab) => { - onSelectionChange?.({ workspace: item.workspace, kind: item.kind ?? "artifact", ...(selectedVersion === "working" ? { name: item.name } : { version_id: selectedVersion }), route: selectedRoute, tab: selectedTab === "preview" ? "preview" : "source" }); + onSelectionChange?.({ workspace: item.workspace, ...(gallery.libraryScope ? { library_scope: gallery.libraryScope } : {}), kind: item.kind ?? "artifact", ...(selectedVersion === "working" ? { name: item.name } : { version_id: selectedVersion }), route: selectedRoute, tab: selectedTab === "preview" ? "preview" : "source" }); }; function selectArtifact(item: GalleryArtifact, preferredTab?: "preview" | "source") { ++lookupGeneration.current; @@ -286,8 +289,13 @@ function Workspace({ initial, view, search, renderPreview, attach, attachedVersi
{error ?

{error}

: null} {notice ?

{notice}

: null} - {!showDetail ?
Library{artifact ? : null}
: null} -
+ {gallery.libraryScope && onLibraryChange ?
+ {gallery.capabilities?.trash ? : null} +
: null} + {!showDetail && !showTrash ?
Library{artifact ? : null}
: null} + {showTrash ? { await load(query); }} onClose={() => setShowTrash(false)} /> :
[...drafts.entries()].some(([key, draft]) => key.startsWith(`${item.key}:`) && draft.dirty)} @@ -320,13 +328,19 @@ function Workspace({ initial, view, search, renderPreview, attach, attachedVersi { setRefresh(value => value + 1); void load(query); }}>Refresh {gallery.capabilities?.links ? setDisclosure("share")}>Share : null} {editing ? setDisclosure("remix")}>Remix : null} + {gallery.capabilities?.trash ? setDisclosure("delete")}>Delete : null} : null} - {openProduct ? void perform(openProduct)}>Open library in browser : null} - {gallery.capabilities?.nativeApps && openProduct ? void perform(openProduct)}>Worker apps : null} + {openProduct ? void perform(() => openProduct(gallery.libraryScope))}>Open library in browser : null} + {gallery.capabilities?.nativeApps && openProduct ? void perform(() => openProduct(gallery.libraryScope))}>Worker apps : null}
: null}
{attachedRevision ?

Revision {attachedRevision.revision} attached

: attachedVersionId && attachedVersionId === resolvedVersion ?

Revision attached

: null} + {artifact && disclosure === "delete" ?
setDisclosure(null)} onDeleted={async () => { + setCatalog(items => items.filter(item => item.key !== artifact.key)); + setSelectedKey(null); setDisclosure(null); setMobileDetail(false); appliedSelection.current = selectionKey; + await load(query); + }} />
: null} {artifact && resolvedVersion && disclosure === "share" ?
Share {artifact.name}
{ await load(query); }} openLink={openLink} /> @@ -358,7 +372,7 @@ function Workspace({ initial, view, search, renderPreview, attach, attachedVersi } -
+
} ; } diff --git a/src/mcp/app-contract.ts b/src/mcp/app-contract.ts index ca2f605..efa911b 100644 --- a/src/mcp/app-contract.ts +++ b/src/mcp/app-contract.ts @@ -1,5 +1,5 @@ // Bump to refresh cached shells; keep older tool-result payloads readable. -const ARTIFACTS_APP_VERSION = 7; +const ARTIFACTS_APP_VERSION = 8; export const ARTIFACTS_APP_URI = `ui://artifacts/v${ARTIFACTS_APP_VERSION}/viewer.html`; /** Saved chat cards keep their original URI across compatible shell updates. */ @@ -25,5 +25,6 @@ export type ArtifactAppPayload = { plugins?: boolean; files?: boolean; workspace?: string; + library_scope?: "private" | "team"; revision?: number; }; diff --git a/src/mcp/discovery.test.ts b/src/mcp/discovery.test.ts index 34d5f13..e609c54 100644 --- a/src/mcp/discovery.test.ts +++ b/src/mcp/discovery.test.ts @@ -15,10 +15,12 @@ test("every hosted operation can be inspected through the stable nine-tool surfa "artifacts_discover", "artifacts_execute", "artifacts_query", ]); const catalog = operationCatalog(CLOUD_MCP_TOOLS); - expect(catalog).toHaveLength(48); + expect(catalog).toHaveLength(53); const noExecution = async () => { throw new Error("Discovery without docs must not execute anything"); }; const listing = await callDiscoveryTool(CLOUD_MCP_TOOLS, "artifacts_discover", { limit: 50 }, noExecution); - expect(listing.structuredContent).toMatchObject({ total: 48, next_offset: null }); + expect(listing.structuredContent).toMatchObject({ total: 53, next_offset: 50 }); + const lastPage = await callDiscoveryTool(CLOUD_MCP_TOOLS, "artifacts_discover", { offset: 50, limit: 50 }, noExecution); + expect(lastPage.structuredContent).toMatchObject({ total: 53, next_offset: null }); for (const operation of catalog) { const result = await callDiscoveryTool(CLOUD_MCP_TOOLS, "artifacts_discover", { operation: operation.name, include_docs: false }, noExecution); expect(result.structuredContent).toMatchObject({ operation: operation.name, inputSchema: operation.inputSchema }); diff --git a/src/mcp/discovery.ts b/src/mcp/discovery.ts index 821af52..847c1b3 100644 --- a/src/mcp/discovery.ts +++ b/src/mcp/discovery.ts @@ -72,7 +72,8 @@ export function compactToolSurface(tools: readonly Tool[]): Tool[] { }).map(tool => ({ ...tool, _meta: { ...tool._meta, ui: { ...(tool._meta?.ui as Record), visibility: ["app"] } }, })); - return [...direct, ...DISCOVERY_TOOLS, ...appTools]; + const discovery = DISCOVERY_TOOLS.map(tool => tools.find(candidate => candidate.name === tool.name) ?? tool); + return [...direct, ...discovery, ...appTools]; } // An explicit allowlist is intentional: newly added operations default to actions. @@ -81,6 +82,7 @@ const READ_OPERATIONS = new Set([ "artifact_guide", "artifact_list", "artifact_read", "artifact_history", "artifact_version", "artifact_export", "artifact_typecheck", "artifact_runs", "script_guide", "script_list", "script_read", "script_history", "script_version", "script_export", "script_logs", "script_runs", "app_guide", "app_list", "app_read", "app_history", "plugins_list", "plugin_guide", + "artifacts_trash", ]); const MIXED_OPERATIONS: Record = { artifact_schedule: "action omitted or get, with no schedule configuration fields", diff --git a/src/mcp/gallery-contract.ts b/src/mcp/gallery-contract.ts index 1421155..ff17c5d 100644 --- a/src/mcp/gallery-contract.ts +++ b/src/mcp/gallery-contract.ts @@ -6,6 +6,7 @@ export const GALLERY_TOOL_NAMES = [ "artifact_write", "artifact_restore", "artifact_remix", "artifact_import", "artifact_export", "artifact_link", "script_write", "script_restore", "script_remix", "script_import", "script_export", "script_run", "script_logs", "script_schedule", "script_runs", "artifact_schedule", "artifact_runs", "artifact_secrets", "script_secrets", + "artifact_delete", "artifact_undelete", "script_delete", "script_undelete", "artifacts_trash", ] as const; export function galleryToolDefinition(tools: readonly Tool[]): Tool { diff --git a/src/mcp/workspace-contract.ts b/src/mcp/workspace-contract.ts index 272a106..6be5184 100644 --- a/src/mcp/workspace-contract.ts +++ b/src/mcp/workspace-contract.ts @@ -59,16 +59,17 @@ export const ARTIFACTS_WORKSPACE_TOOLS: Tool[] = [ }, ]; -export function artifactResourceUri(item: Pick, versionId?: string): string { +export function artifactResourceUri(item: Pick, versionId?: string, libraryScope?: "private" | "team"): string { const query = new URLSearchParams({ workspace: item.workspace, kind: item.kind ?? "artifact", name: item.name }); if (versionId) query.set("version_id", versionId); + if (libraryScope) query.set("library_scope", libraryScope); return `artifact://project?${query}`; } /** Resource identity is descriptive; every read must repeat service authorization. */ export function parseArtifactResourceUri(uri: string) { const url = new URL(uri); - const allowed = new Set(["workspace", "kind", "name", "version_id"]); + const allowed = new Set(["workspace", "kind", "name", "version_id", "library_scope"]); if (url.protocol !== "artifact:" || url.hostname !== "project" || url.pathname || url.hash || [...url.searchParams.keys()].some(key => !allowed.has(key) || url.searchParams.getAll(key).length !== 1)) throw new Error("unknown artifact resource"); const workspace = url.searchParams.get("workspace"); @@ -77,7 +78,9 @@ export function parseArtifactResourceUri(uri: string) { if (!workspace || !name || (kind !== "artifact" && kind !== "script")) throw new Error("invalid artifact resource"); const version_id = url.searchParams.get("version_id"); if (version_id === "") throw new Error("invalid artifact version"); - return { workspace, kind, name, ...(version_id ? { version_id } : {}) } as { workspace: string; kind: "artifact" | "script"; name: string; version_id?: string }; + const library_scope = url.searchParams.get("library_scope"); + if (library_scope !== null && library_scope !== "private" && library_scope !== "team") throw new Error("invalid library scope"); + return { workspace, kind, name, ...(version_id ? { version_id } : {}), ...(library_scope ? { library_scope } : {}) } as { workspace: string; kind: "artifact" | "script"; name: string; version_id?: string; library_scope?: "private" | "team" }; } export function workspaceResult(payload: ArtifactWorkspacePayload) { diff --git a/src/runtime/mcp-app.ts b/src/runtime/mcp-app.ts index bc5fd2e..4b691bb 100644 --- a/src/runtime/mcp-app.ts +++ b/src/runtime/mcp-app.ts @@ -168,7 +168,7 @@ function theme(kind?: string) { async function serverRequest(artifact: ArtifactAppPayload, request: ArtifactHttpRequest): Promise { const result = await app.callServerTool({ name: "artifact_request", - arguments: { version_id: artifact.versionId, ...(artifact.workspace ? { workspace: artifact.workspace } : {}), request }, + arguments: { version_id: artifact.versionId, ...(artifact.workspace ? { workspace: artifact.workspace } : {}), ...(artifact.library_scope ? { library_scope: artifact.library_scope } : {}), request }, }); if (result.isError) { const message = result.content?.filter(item => item.type === "text").map(item => item.text).join("\n"); @@ -208,14 +208,14 @@ function clear() { updateDisplay(); } -type CompactSelection = { versionId?: string; eventId?: string; workspace?: string }; +type CompactSelection = { versionId?: string; eventId?: string; workspace?: string; library_scope?: "private" | "team" }; type CompactToolResult = { artifact?: CompactSelection; preview?: { artifact?: CompactSelection }; appPreview?: ArtifactAppPayload; appWorkspace?: ArtifactWorkspacePayload }; async function openSelection(selection: ArtifactSelection) { if (fileEditor?.hasUnsavedChanges()) throw new Error("Save or discard the file draft before opening another view."); if (!host.capabilities().serverTools) throw new Error("Browsing artifacts is unavailable in this host."); selectedArtifact = selection; - if (workspaceView) { workspaceView.update({ selection }); return; } + if (workspaceView) { await workspaceView.update({ selection }); return; } const request = ++opening; const artifact = await fetchArtifactPreview(app, selection); if (request !== opening) return; @@ -246,7 +246,7 @@ function createArtifactBridge(artifact: ArtifactAppPayload, route?: string, prev bridgeVersions.set(bridge, artifact.versionId); if (capabilities.modelContext) bridge.onModelContext = async view => { const epoch = contextEpoch; - const snapshot = view === null ? undefined : { artifact: { name: artifact.name, workspace: artifact.workspace, version_id: artifact.versionId, revision: artifact.revision }, view }; + const snapshot = view === null ? undefined : { artifact: { name: artifact.name, workspace: artifact.workspace, version_id: artifact.versionId, revision: artifact.revision, ...(artifact.library_scope ? { library_scope: artifact.library_scope } : {}) }, view }; const result = await host.updateContext(snapshot ? { structuredContent: snapshot, content: [{ type: "text", text: JSON.stringify(snapshot), _meta: { "openai/title": artifact.name } }], @@ -263,7 +263,7 @@ function createArtifactBridge(artifact: ArtifactAppPayload, route?: string, prev if (artifact.server === true && capabilities.serverTools) bridge.onRequest = request => serverRequest(artifact, request); if (artifact.files === true && capabilities.serverTools) { bridge.onFileRequest = async request => { - const result = await app.callServerTool({ name: "artifact_files", arguments: { version_id: artifact.versionId, ...(artifact.workspace ? { workspace: artifact.workspace } : {}), request } }); + const result = await app.callServerTool({ name: "artifact_files", arguments: { version_id: artifact.versionId, ...(artifact.workspace ? { workspace: artifact.workspace } : {}), ...(artifact.library_scope ? { library_scope: artifact.library_scope } : {}), request } }); if (result.isError) throw new Error(result.content?.filter(item => item.type === "text").map(item => item.text).join("\n") || "Artifact file request failed"); if (!result.structuredContent || !Object.hasOwn(result.structuredContent, "result")) throw new Error("Artifact file result was missing"); return result.structuredContent.result; @@ -284,7 +284,7 @@ function createArtifactBridge(artifact: ArtifactAppPayload, route?: string, prev } function renderArtifact(artifact: ArtifactAppPayload, route?: string, preserve = false) { - const previous = preserve && activeArtifact?.name === artifact.name && activeArtifact?.workspace === artifact.workspace ? hostWindow.__artifacts : undefined; + const previous = preserve && activeArtifact?.name === artifact.name && activeArtifact?.workspace === artifact.workspace && activeArtifact?.library_scope === artifact.library_scope ? hostWindow.__artifacts : undefined; clear(); activeArtifact = artifact; const bridge = createArtifactBridge(artifact, route, previous); @@ -304,10 +304,10 @@ function renderArtifact(artifact: ArtifactAppPayload, route?: string, preserve = } async function renderWorkspacePreview(container: HTMLElement, item: GalleryArtifact, version: string, route?: string) { - const selection = { workspace: item.workspace, ...(version === "working" ? { name: item.name } : { version_id: version }) }; + const selection = { workspace: item.workspace, ...(item.library_scope ? { library_scope: item.library_scope } : {}), ...(version === "working" ? { name: item.name } : { version_id: version }) }; const artifact = await fetchArtifactPreview(app, selection); if (!container.isConnected) return () => {}; - const key = JSON.stringify([item.key, version]); + const key = JSON.stringify([item.library_scope, item.key, version]); const previous = previewState.get(key); const bridge = createArtifactBridge(artifact, previous?.route?.path ?? route, previous); const preview = mountMcpPreview(container, artifact.js, bridge, message => { status.textContent = message; }); @@ -324,7 +324,7 @@ async function attachWorkspaceArtifact(item: GalleryArtifact, version: string) { await bridge.onModelContext({ ...bridge.modelContext, route: bridge.route?.path ?? "/" }); return; } - const artifact = await fetchArtifactPreview(app, { workspace: item.workspace, ...(version === "working" ? { name: item.name } : { version_id: version }) }); + const artifact = await fetchArtifactPreview(app, { workspace: item.workspace, ...(item.library_scope ? { library_scope: item.library_scope } : {}), ...(version === "working" ? { name: item.name } : { version_id: version }) }); const temporary = createArtifactBridge(artifact); try { if (!temporary.onModelContext) throw new Error("Adding context is unavailable in this chat"); @@ -355,8 +355,10 @@ function showWorkspace(workspace: ArtifactWorkspacePayload) { const result = await app.openLink({ url }); if (result.isError) throw new Error("The host could not open the link"); }, - ...(workspace.productUrl ? { openProduct: async () => { - const result = await app.openLink({ url: workspace.productUrl! }); + ...(workspace.productUrl ? { openProduct: async (scope?: "private" | "team") => { + const url = new URL(workspace.productUrl!); + if (scope) url.searchParams.set("library", scope); + const result = await app.openLink({ url: url.href }); if (result.isError) throw new Error("The host could not open the library"); } } : {}), ...(host.capabilities().actions.promptAgent ? { askCreate: async () => { @@ -364,6 +366,7 @@ function showWorkspace(workspace: ArtifactWorkspacePayload) { if (result.isError) throw new Error("The host could not send the request"); } } : {}), }); + void workspaceView.ready.catch(error => { status.textContent = String(error); }); updateDisplay(); } app.ontoolresult = result => { @@ -381,7 +384,7 @@ app.ontoolresult = result => { } const selected = compact?.artifact ?? compact?.preview?.artifact; if (selected?.versionId && host.capabilities().serverTools) { - void openSelection({ workspace: selected.workspace, version_id: selected.versionId, event_id: selected.eventId }).catch(error => { status.textContent = String(error); }); + void openSelection({ workspace: selected.workspace, version_id: selected.versionId, event_id: selected.eventId, ...(selected.library_scope ? { library_scope: selected.library_scope } : {}) }).catch(error => { status.textContent = String(error); }); return; } const workspace = (result._meta?.workspace ?? compact?.appWorkspace) as ArtifactWorkspacePayload | undefined; diff --git a/src/runtime/mcp-workspace.ts b/src/runtime/mcp-workspace.ts index e625926..e36c862 100644 --- a/src/runtime/mcp-workspace.ts +++ b/src/runtime/mcp-workspace.ts @@ -9,7 +9,7 @@ import { GalleryToolError, type GalleryTransport, type MutationResult } from ".. import type { SourceSnapshot } from "../gallery/drafts"; import { editableProject } from "../gallery/project-editor"; -export type ArtifactSelection = { name?: string; workspace?: string; version_id?: string; event_id?: string; route?: string; kind?: "artifact" | "script"; tab?: "preview" | "source" }; +export type ArtifactSelection = { name?: string; workspace?: string; library_scope?: "private" | "team"; version_id?: string; event_id?: string; route?: string; kind?: "artifact" | "script"; tab?: "preview" | "source" }; /** Deep links contain authenticated selectors, never file-transfer credentials. */ export function parseArtifactDeepLink(value: string): ArtifactSelection | null { @@ -23,8 +23,10 @@ export function parseArtifactDeepLink(value: string): ArtifactSelection | null { const route = url.searchParams.get("route") ?? "/"; const kind = url.searchParams.get("kind"); const tab = url.searchParams.get("tab"); + const library_scope = url.searchParams.get("library_scope"); + if (library_scope !== null && library_scope !== "private" && library_scope !== "team") return null; if ((!name && !version_id) || !route.startsWith("/") || route.startsWith("//") || route.includes("\\") || (kind && !["artifact", "script"].includes(kind)) || (tab && !["preview", "source"].includes(tab))) return null; - return { name: version_id ? undefined : name, workspace, version_id, route, ...(kind ? { kind: kind as "artifact" | "script" } : {}), ...(tab ? { tab: tab as "preview" | "source" } : {}) }; + return { name: version_id ? undefined : name, workspace, version_id, route, ...(library_scope ? { library_scope } : {}), ...(kind ? { kind: kind as "artifact" | "script" } : {}), ...(tab ? { tab: tab as "preview" | "source" } : {}) }; } export async function fetchArtifactPreview(app: App, selection: ArtifactSelection): Promise { @@ -56,16 +58,17 @@ function resultValue(result: Awaited>) { return value; } -export function createGalleryTransport(app: App): GalleryTransport { +export function createGalleryTransport(app: App, scope?: "private" | "team"): GalleryTransport { + const library = scope ? { library_scope: scope } : {}; return { async tool(workspace, tool, args) { - return resultValue(await app.callServerTool({ name: "artifacts_tool", arguments: { workspace, tool, arguments: args } })); + return resultValue(await app.callServerTool({ name: "artifacts_tool", arguments: { workspace, tool, arguments: args, ...library } })); }, async loadSource(sourceUrl, signal) { signal?.throwIfAborted(); const params = new URL(sourceUrl, "https://artifacts.invalid").searchParams; const selection = params.get("version_id") ? { version_id: params.get("version_id")! } : { name: params.get("name")! }; - const result = await app.callServerTool({ name: "artifacts_source", arguments: { workspace: params.get("workspace")!, kind: params.get("kind") === "script" ? "script" : "artifact", ...selection } }); + const result = await app.callServerTool({ name: "artifacts_source", arguments: { workspace: params.get("workspace")!, kind: params.get("kind") === "script" ? "script" : "artifact", ...selection, ...library } }); signal?.throwIfAborted(); const snapshot = resultValue(result) as SourceSnapshot; if (typeof snapshot?.source !== "string" || !snapshot.project) throw new Error("The server returned an incomplete project snapshot"); @@ -79,22 +82,49 @@ export type WorkspaceOptions = Pick { - const result = await app.callServerTool({ name: "artifacts_search", arguments: { query, offset, view: "library" } }); + let libraryRequest = 0; + let disposed = false; + const searchScope = async (query: string, offset: number, selectedScope = scope) => { + const result = await app.callServerTool({ name: "artifacts_search", arguments: { query, offset, view: "library", ...(selectedScope ? { library_scope: selectedScope } : {}) } }); if (result.isError) resultValue(result); const payload = (result._meta?.workspace ?? result.structuredContent?.appWorkspace) as ArtifactWorkspacePayload | undefined; if (!payload) throw new Error("The server returned no library catalog"); return workspaceGallery(payload); }; - const render = () => reactRoot.render(createElement(GalleryWorkspace, { initial: gallery, view: initial.view, transport, search, ...current, refreshKey })); + let transport = createGalleryTransport(app, scope); + const initialScope = scope; + let search = (query: string, offset: number) => searchScope(query, offset, initialScope); + const changeLibrary = async (selectedScope: "private" | "team", selection?: ArtifactSelection) => { + const generation = ++libraryRequest; + const page = await searchScope("", 0, selectedScope); + if (disposed || generation !== libraryRequest) return; + scope = selectedScope; gallery = page; + current = { ...current, selection }; + transport = createGalleryTransport(app, scope); + // Capture the selected scope so requests from the old view cannot change + // libraries halfway through pagination or a save. + search = (query, offset) => searchScope(query, offset, selectedScope); + render(); + }; + const render = () => reactRoot.render(createElement(GalleryWorkspace, { key: gallery.libraryScope, initial: gallery, view: initial.view, transport, search, ...current, + ...(gallery.libraryScope ? { onLibraryChange: changeLibrary } : {}), refreshKey })); render(); + const ready = options.selection?.library_scope && options.selection.library_scope !== scope + ? changeLibrary(options.selection.library_scope, options.selection) : Promise.resolve(); return { - update(value: Partial) { current = { ...current, ...value }; render(); }, + ready, + async update(value: Partial) { + if (value.selection?.library_scope && value.selection.library_scope !== scope) { + await changeLibrary(value.selection.library_scope, value.selection); + return; + } + current = { ...current, ...value }; render(); + }, refresh() { refreshKey++; render(); }, - dispose() { reactRoot.unmount(); }, + dispose() { disposed = true; ++libraryRequest; reactRoot.unmount(); }, }; } From c6a23da315b3d87ecfe38c6a930c5d2163fffa95 Mon Sep 17 00:00:00 2001 From: Nico Ritschel Date: Mon, 2 Nov 2026 16:27:08 -0800 Subject: [PATCH 2/2] Exercise Trash operations in the MCP discovery integration test The catalog grew to 53 operations across two discovery pages. Cover the second page and the new delete, undelete and Trash operations, and use a fresh connection for the cross-origin auth-flow request. --- cloudflare/auth-flow.test.ts | 3 ++- cloudflare/mcp-discovery.test.ts | 17 +++++++++++++---- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/cloudflare/auth-flow.test.ts b/cloudflare/auth-flow.test.ts index 46efc6d..2c0f506 100644 --- a/cloudflare/auth-flow.test.ts +++ b/cloudflare/auth-flow.test.ts @@ -276,7 +276,8 @@ test("real OIDC and MCP OAuth share per-user gallery, source and database bounda const body = await frame.locator("body").innerText({ timeout: 500 }).catch(() => "Preview body unavailable"); throw new Error(`${String(error)}\nGallery: ${bobPage.url()}\nPreview: ${await bobPage.locator("iframe").getAttribute("src")}\n${body}\n${previewRequests.join("\n")}\n${celldRuntime?.logs().split("\n").filter(line => /ERROR|WARN| at | at /.test(line)).join("\n") ?? ""}`); } - expect((await aliceContext.request.post(`${origin}/api/artifact/request`, { headers: { Origin: "https://evil.example" }, data: {} })).status()).toBe(403); + // A fresh connection: the preview wait can outlive the server's idle keep-alive socket. + expect((await aliceContext.request.post(`${origin}/api/artifact/request`, { headers: { Origin: "https://evil.example", Connection: "close" }, data: {} })).status()).toBe(403); const token = aliceProvider.savedTokens!.access_token; expect((await fetch(`${origin}/mcp`, { headers: { Authorization: `Bearer ${token.slice(0, -10)}tampered00` } })).status).toBe(401); const cookie = (await aliceContext.cookies()).find(cookie => cookie.name.endsWith("session_token"))!; diff --git a/cloudflare/mcp-discovery.test.ts b/cloudflare/mcp-discovery.test.ts index 0ffbe44..383c715 100644 --- a/cloudflare/mcp-discovery.test.ts +++ b/cloudflare/mcp-discovery.test.ts @@ -128,7 +128,7 @@ afterAll(async () => { if (directory) await rm(directory, { recursive: true, force: true }); }); -integrationTest("one HTTP MCP connection discovers all 48 operations and performs complete artifact, script and native app workflows", async () => { +integrationTest("one HTTP MCP connection discovers all 53 operations and performs complete artifact, script and native app workflows", async () => { const listed = (await client.listTools()).tools; const model = listed.filter(tool => !((tool._meta?.ui as { visibility?: string[] } | undefined)?.visibility?.every(scope => scope === "app"))); expect(model.map(tool => tool.name).sort()).toEqual([ @@ -136,8 +136,10 @@ integrationTest("one HTTP MCP connection discovers all 48 operations and perform "artifacts_discover", "artifacts_execute", "artifacts_query", ]); const catalog = payload(await client.callTool({ name: "artifacts_discover", arguments: { limit: 50 } })); - expect(catalog.total).toBe(48); - for (const operation of catalog.operations) await inspect(operation.operation); + expect(catalog.total).toBe(53); + const rest = payload(await client.callTool({ name: "artifacts_discover", arguments: { offset: 50, limit: 50 } })); + expect(rest.next_offset).toBeNull(); + for (const operation of [...catalog.operations, ...rest.operations]) await inspect(operation.operation); const backend = await readFile(join(import.meta.dir, "../examples/counter.artifact.server.ts"), "utf8"); const guide = await invoke("artifact_guide"); expect(JSON.stringify(guide.content)).not.toContain("Hosted standalone scripts and root URLs"); @@ -198,6 +200,13 @@ integrationTest("one HTTP MCP connection discovers all 48 operations and perform const scriptArchive = payload(await invoke("script_export", { name: "compact-script" })); await invoke("script_import", { new_name: "compact-script-import", archive: scriptArchive }); expect(JSON.stringify(scriptArchive)).not.toContain("script-fixture-value"); + await invoke("artifact_delete", { name: "compact-import" }); + await invoke("script_delete", { name: "compact-script-import" }); + const trash = JSON.stringify(payload(await invoke("artifacts_trash"))); + expect(trash).toContain("compact-import"); + expect(trash).toContain("compact-script-import"); + await invoke("artifact_undelete", { name: "compact-import" }); + await invoke("script_undelete", { name: "compact-script-import" }); await invoke("plugin_guide"); const plugins = payload(await invoke("plugins_list")); @@ -225,7 +234,7 @@ integrationTest("one HTTP MCP connection discovers all 48 operations and perform expect((await ownerFetch(appUrl)).status).toBe(404); expect(await (await otherFetch(appUrl + "&library=team")).text()).toBe("native-original"); } - const expected = catalog.operations.map((operation: { operation: string }) => operation.operation).filter((name: string) => native || !name.startsWith("app_")); + const expected = [...catalog.operations, ...rest.operations].map((operation: { operation: string }) => operation.operation).filter((name: string) => native || !name.startsWith("app_")); expect([...seen].sort()).toEqual(expected.sort()); expect((await client.listTools()).tools).toEqual(listed); }, 180_000);