From e44222d5d41fbd2d7314e92a2ea2ba8b7fb83c09 Mon Sep 17 00:00:00 2001 From: GZX Date: Sun, 6 Sep 2026 14:15:39 +0800 Subject: [PATCH] feat: export workspace files as MCP resources Add one read-only export_artifact tool that exposes short-lived, workspace-contained files through MCP resources so hosts can materialize them as native attachments. Keep incoming downloads unchanged, bound resource reads by the configured limit and an 8 MiB ceiling, and cover cross-session text and binary materialization. Co-Authored-By: ChatGPT GPT-5.6 Sol --- README.md | 2 +- docs/artifact-exchange.md | 56 ++++- docs/configuration.md | 15 +- docs/security.md | 32 ++- src/artifact-export.test.ts | 200 +++++++++++++++++ src/artifact-export.ts | 412 ++++++++++++++++++++++++++++++++++++ src/server.ts | 28 ++- 7 files changed, 709 insertions(+), 36 deletions(-) create mode 100644 src/artifact-export.test.ts create mode 100644 src/artifact-export.ts diff --git a/README.md b/README.md index 381930db8..1c4c45c24 100644 --- a/README.md +++ b/README.md @@ -185,7 +185,7 @@ devspace doctor - [Setup Guide](https://github.com/Waishnav/devspace/blob/main/docs/setup.md) - [ChatGPT Coding Workflow](https://github.com/Waishnav/devspace/blob/main/docs/chatgpt-coding-workflow.md) - [Configuration Reference](https://github.com/Waishnav/devspace/blob/main/docs/configuration.md) -- [Native File Download](https://github.com/Waishnav/devspace/blob/main/docs/artifact-exchange.md) +- [Native File Exchange](https://github.com/Waishnav/devspace/blob/main/docs/artifact-exchange.md) - [Security Model](https://github.com/Waishnav/devspace/blob/main/docs/security.md) - [Troubleshooting Gotchas](https://github.com/Waishnav/devspace/blob/main/docs/gotchas.md) diff --git a/docs/artifact-exchange.md b/docs/artifact-exchange.md index f4728eb0d..549c868bd 100644 --- a/docs/artifact-exchange.md +++ b/docs/artifact-exchange.md @@ -1,10 +1,46 @@ -# Download a native file +# Exchange native files -DevSpace can save a file attached or generated by an MCP host, such as ChatGPT, -directly into an open workspace. Enable the tool with -`artifacts.enabled` in `~/.devspace/config.jsonc`. +Enable artifact tools with `artifacts.enabled` in `~/.devspace/config.jsonc`. +DevSpace supports both directions: attaching an existing workspace file back to +the MCP host, and downloading a native host file into a workspace. + +## Attach a workspace file to the host + +Use `export_artifact` when the user wants the original file as an attachment, +rather than only asking DevSpace to inspect its contents. + +```text +open_workspace + -> export_artifact({ workspaceId, path }) + -> MCP resource link + -> host reads the resource and materializes the attachment +``` + +```text +export_artifact({ + workspaceId: "ws_123", + path: "dist/report.pdf" +}) +``` + +The source path must resolve to a regular file inside the selected workspace. +Symlinks whose target escapes the workspace are rejected. DevSpace opens and +pins the selected file while the export is active, so a later path replacement +does not change which bytes the host receives. + +Exports are short-lived MCP resources under `artifact://devspace/...`. The +resource expires after five minutes. Text resources are returned through +`resources/read` as text; binary resources use the MCP `blob` field. The +materialization limit is the lower of `artifacts.maxFileBytes` and 8 MiB, which +bounds the base64 expansion required for binary MCP resource responses. No +public artifact HTTP endpoint or persistent artifact store is created. -## Workflow +The export direction works on every platform supported by DevSpace. + +## Download a host file into a workspace + +DevSpace can save a file attached or generated by an MCP host, such as ChatGPT, +directly into an open workspace. ```text open_workspace @@ -29,8 +65,6 @@ DevSpace creates missing parent directories and refuses to overwrite an existing file. After the download, normal tools can inspect, move, rename, replace, or delete it. -## Safety and limits - The `file` input must be the native value supplied by the MCP host. DevSpace does not accept pasted download URLs or local source paths. It validates the complete file-object shape, trusted OpenAI download hosts, and redirects before streaming. @@ -38,7 +72,7 @@ Malformed references, unknown fields, absolute paths, traversal, and symlinked parents are rejected. Downloads are streamed under `artifacts.maxFileBytes` and published as -owner-only files without overwriting an existing destination. The tool is -currently available on Linux. It is not registered on macOS, Windows, or BSD -because Node.js does not expose the required descriptor-relative filesystem -operations there. +owner-only files without overwriting an existing destination. The download +direction is currently available only on Linux. It is not registered on macOS, +Windows, or BSD because Node.js does not expose the required descriptor-relative +filesystem operations there. diff --git a/docs/configuration.md b/docs/configuration.md index 6a6f607bd..c31798d53 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -135,12 +135,15 @@ are `CODEX_COMMAND`, `CODEX_HOME`, `CLAUDE_COMMAND`, `CURSOR_COMMAND`, `COPILOT_COMMAND`, `GROK_COMMAND`, and `GROK_AGENT_PROFILE`. DevSpace does not persist provider credentials. -## Native artifact download - -Set `artifacts.enabled` to `true` when a host needs to save a native attached or -generated file into an open workspace. `artifacts.maxFileBytes` limits one -streamed file. The secure publication path is currently available only on -Linux; the tool is not registered on macOS, Windows, or BSD. +## Native artifact exchange + +Set `artifacts.enabled` to `true` to exchange native files with the MCP host. +`export_artifact` can attach an existing workspace file through a short-lived +MCP resource on every supported platform; its effective size limit is the lower +of `artifacts.maxFileBytes` and 8 MiB. `download_artifact` saves a host-provided +native file into a workspace, with `artifacts.maxFileBytes` limiting one streamed +download. The secure download publication path is currently available only on +Linux; `download_artifact` is not registered on macOS, Windows, or BSD. ## Environment boundary diff --git a/docs/security.md b/docs/security.md index 69bbc1303..efb7893bf 100644 --- a/docs/security.md +++ b/docs/security.md @@ -92,13 +92,22 @@ Managed worktrees reduce accidental edits to your active checkout, but they are not a security boundary. They are a workflow boundary for isolated coding sessions. -## Native File Download - -Native file download is an opt-in, one-shot transfer into an already-open -workspace. `download_artifact` accepts the MCP host's native file value, the -`workspaceId` returned by `open_workspace`, and an unused relative destination -path. It returns only the workspace-relative path and does not create a -persistent artifact service or reusable artifact ID. +## Native File Exchange + +Artifact exchange is opt-in. `export_artifact` exposes one existing workspace +file as a short-lived MCP resource so the host can materialize it as an +attachment. The source is canonicalized before export; symlinks whose real +target escapes the selected workspace are rejected. The selected file handle is +pinned for the five-minute resource lifetime. Export size is capped by the lower +of `artifacts.maxFileBytes` and 8 MiB. Binary `resources/read` responses use the +MCP `blob` field, so the limit also bounds base64 expansion in the MCP response. +No public download endpoint or persistent artifact store is created for exports. + +Native file download is a one-shot transfer into an already-open workspace. +`download_artifact` accepts the MCP host's native file value, the `workspaceId` +returned by `open_workspace`, and an unused relative destination path. It +returns only the workspace-relative path and does not create a persistent +artifact service or reusable artifact ID. DevSpace accepts only the documented native-file object and trusted OpenAI download hosts and redirects. Arbitrary URL strings, local source paths, @@ -116,9 +125,12 @@ disabled unless `logging.shellCommands` is `true`. Do not enable shell command logging if commands may contain secrets. -Artifact tool logs contain bounded workspace ID, validated hostname, +Download artifact logs contain bounded workspace ID, validated hostname, workspace-relative output path, byte count, hash, duration, and status metadata. +Export logs contain only the tool name, workspace ID, byte count, duration, and +status metadata; they do not include the source path or resource token. `download_artifact` does not log the opaque file value. Raw content, connector references, native file IDs, bearer credentials, presigned URLs, host paths, -temporary paths, and base64 chunks are never included in tool logs or tool -results. +temporary paths, and base64 chunks are never included in tool logs. Exported +file bytes are absent from the `export_artifact` tool result itself; the host +retrieves them only in the follow-up MCP `resources/read` response. diff --git a/src/artifact-export.test.ts b/src/artifact-export.test.ts new file mode 100644 index 000000000..f31a5c145 --- /dev/null +++ b/src/artifact-export.test.ts @@ -0,0 +1,200 @@ +import assert from "node:assert/strict"; +import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { test } from "node:test"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { + ARTIFACT_RESOURCE_MAX_BYTES, + clearExportedArtifactsForTests, + exportWorkspaceArtifact, + readExportedArtifactResource, + registerArtifactExportTool, +} from "./artifact-export.js"; +import type { ServerConfig } from "./config.js"; +import type { WorkspaceRegistry } from "./workspaces.js"; + +async function fixture(t: { after(callback: () => void | Promise): void }) { + const root = await mkdtemp(join(tmpdir(), "devspace-artifact-export-")); + const workspace = join(root, "workspace"); + const outside = join(root, "outside"); + await Promise.all([mkdir(workspace), mkdir(outside)]); + t.after(async () => { + await clearExportedArtifactsForTests(); + await rm(root, { recursive: true, force: true }); + }); + return { workspace, outside }; +} + +function workspaceRegistry(root: string): WorkspaceRegistry { + return { + getWorkspace(id: string) { + assert.equal(id, "ws_test"); + return { id, root }; + }, + resolvePath(_workspace: unknown, path: string) { + return resolve(root, path); + }, + } as unknown as WorkspaceRegistry; +} + +async function connectedServer(root: string) { + const server = new McpServer({ name: "artifact-export-test", version: "1.0.0" }); + registerArtifactExportTool(server, { + config: { + artifactMaxFileBytes: ARTIFACT_RESOURCE_MAX_BYTES, + logging: { toolCalls: false }, + } as unknown as ServerConfig, + workspaces: workspaceRegistry(root), + }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const client = new Client({ name: "artifact-export-test-client", version: "1.0.0" }); + await Promise.all([ + client.connect(clientTransport), + server.connect(serverTransport), + ]); + return { + client, + close: async () => { + await client.close(); + await server.close(); + }, + }; +} + +test("export_artifact materializes through resources/read across MCP sessions", async (t) => { + const { workspace } = await fixture(t); + const bytes = Buffer.from("artifact-diagnostic-marker\n", "utf8"); + await writeFile(join(workspace, "note.txt"), bytes); + + const first = await connectedServer(workspace); + const exported = await first.client.callTool({ + name: "export_artifact", + arguments: { workspaceId: "ws_test", path: "note.txt" }, + }); + await first.close(); + + const content = exported.content as Array<{ + type: string; + uri?: string; + name?: string; + mimeType?: string; + size?: number; + }>; + const link = content.find((item) => item.type === "resource_link"); + assert.ok(link?.uri); + assert.equal(link.name, "note.txt"); + assert.equal(link.mimeType, "text/plain; charset=utf-8"); + assert.equal(link.size, bytes.length); + assert.match(link.uri, /^artifact:\/\/devspace\/[A-Za-z0-9_-]{43}$/); + assert.equal(JSON.stringify(exported).includes(bytes.toString("base64")), false); + + const second = await connectedServer(workspace); + const read = await second.client.readResource({ uri: link.uri }); + await second.close(); + assert.deepEqual(read.contents, [{ + uri: link.uri, + mimeType: "text/plain; charset=utf-8", + text: bytes.toString("utf8"), + }]); +}); + +test("binary resources use MCP blob content", async (t) => { + const { workspace } = await fixture(t); + const bytes = Buffer.from([0, 1, 2, 255, 10]); + const filePath = join(workspace, "payload.bin"); + await writeFile(filePath, bytes); + + const exported = await exportWorkspaceArtifact({ workspaceRoot: workspace, filePath }); + const token = exported.uri.split("/").at(-1) ?? ""; + const read = await readExportedArtifactResource(token, exported.uri); + assert.deepEqual(read.contents, [{ + uri: exported.uri, + mimeType: "application/octet-stream", + blob: bytes.toString("base64"), + }]); +}); + +test("the exact 8 MiB resource boundary remains exportable", async (t) => { + const { workspace } = await fixture(t); + const filePath = join(workspace, "boundary.bin"); + await writeFile(filePath, Buffer.alloc(ARTIFACT_RESOURCE_MAX_BYTES, 0x5a)); + + const exported = await exportWorkspaceArtifact({ workspaceRoot: workspace, filePath }); + assert.equal(exported.size, ARTIFACT_RESOURCE_MAX_BYTES); +}); + +test("files larger than the MCP resource limit are rejected", async (t) => { + const { workspace } = await fixture(t); + const filePath = join(workspace, "too-large.bin"); + await writeFile(filePath, Buffer.alloc(ARTIFACT_RESOURCE_MAX_BYTES + 1, 0x5a)); + + await assert.rejects( + exportWorkspaceArtifact({ workspaceRoot: workspace, filePath }), + /configured MCP resource materialization limit/, + ); +}); + +test("a lower configured per-file limit is enforced", async (t) => { + const { workspace } = await fixture(t); + const filePath = join(workspace, "configured-limit.bin"); + await writeFile(filePath, Buffer.alloc(5)); + + await assert.rejects( + exportWorkspaceArtifact({ + workspaceRoot: workspace, + filePath, + maxFileBytes: 4, + }), + /configured MCP resource materialization limit/, + ); +}); + +test("missing sources fail without exposing their absolute path", async (t) => { + const { workspace } = await fixture(t); + const filePath = join(workspace, "does-not-exist.txt"); + + await assert.rejects( + exportWorkspaceArtifact({ workspaceRoot: workspace, filePath }), + (error: unknown) => { + assert.ok(error instanceof Error); + assert.match(error.message, /existing regular file inside the selected workspace/); + assert.equal(error.message.includes(workspace), false); + return true; + }, + ); +}); + +test("symlinks resolving outside the workspace are rejected", async (t) => { + if (process.platform === "win32") t.skip("symlink fixture differs on Windows"); + const { workspace, outside } = await fixture(t); + const outsideFile = join(outside, "secret.txt"); + const linkedFile = join(workspace, "linked.txt"); + await writeFile(outsideFile, "secret"); + await symlink(outsideFile, linkedFile); + + await assert.rejects( + exportWorkspaceArtifact({ workspaceRoot: workspace, filePath: linkedFile }), + /must resolve to a file inside the selected workspace/, + ); +}); + +test("expired resources cannot be read", async (t) => { + const { workspace } = await fixture(t); + const filePath = join(workspace, "short-lived.txt"); + await writeFile(filePath, "short-lived"); + const exported = await exportWorkspaceArtifact({ + workspaceRoot: workspace, + filePath, + ttlMs: 5, + }); + const token = exported.uri.split("/").at(-1) ?? ""; + await new Promise((resolvePromise) => setTimeout(resolvePromise, 15)); + + await assert.rejects( + readExportedArtifactResource(token, exported.uri), + /no longer available/, + ); +}); diff --git a/src/artifact-export.ts b/src/artifact-export.ts new file mode 100644 index 000000000..238a2b4ae --- /dev/null +++ b/src/artifact-export.ts @@ -0,0 +1,412 @@ +import { randomBytes } from "node:crypto"; +import { constants as fsConstants } from "node:fs"; +import { lstat, open, realpath, type FileHandle } from "node:fs/promises"; +import { basename, extname, isAbsolute, relative, sep } from "node:path"; +import { ResourceTemplate, type McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import * as z from "zod/v4"; +import { ArtifactError } from "./artifact-error.js"; +import type { ServerConfig } from "./config.js"; +import { logEvent } from "./logger.js"; +import type { WorkspaceRegistry } from "./workspaces.js"; + +export const ARTIFACT_EXPORT_TTL_MS = 5 * 60 * 1_000; +export const ARTIFACT_RESOURCE_MAX_BYTES = 8 * 1024 * 1024; +const MAX_ACTIVE_EXPORTS = 128; +const NO_FOLLOW = fsConstants.O_NOFOLLOW ?? 0; +const TOKEN_PATTERN = /^[A-Za-z0-9_-]{43}$/; + +const MIME_TYPES = new Map([ + [".avif", "image/avif"], + [".bmp", "image/bmp"], + [".css", "text/css; charset=utf-8"], + [".csv", "text/csv; charset=utf-8"], + [".gif", "image/gif"], + [".htm", "text/html; charset=utf-8"], + [".html", "text/html; charset=utf-8"], + [".ico", "image/x-icon"], + [".jpeg", "image/jpeg"], + [".jpg", "image/jpeg"], + [".js", "text/javascript; charset=utf-8"], + [".json", "application/json; charset=utf-8"], + [".md", "text/markdown; charset=utf-8"], + [".mjs", "text/javascript; charset=utf-8"], + [".mov", "video/quicktime"], + [".mp3", "audio/mpeg"], + [".mp4", "video/mp4"], + [".pdf", "application/pdf"], + [".png", "image/png"], + [".svg", "image/svg+xml"], + [".tar", "application/x-tar"], + [".text", "text/plain; charset=utf-8"], + [".ts", "text/plain; charset=utf-8"], + [".txt", "text/plain; charset=utf-8"], + [".wav", "audio/wav"], + [".webm", "video/webm"], + [".webp", "image/webp"], + [".xml", "application/xml"], + [".yaml", "application/yaml"], + [".yml", "application/yaml"], + [".zip", "application/zip"], +]); + +interface ExportedArtifact { + token: string; + handle: FileHandle; + name: string; + mimeType: string; + size: number; + expiresAtMs: number; + activeReads: number; + expired: boolean; + timer: NodeJS.Timeout | undefined; +} + +export interface ExportArtifactResult { + name: string; + mimeType: string; + size: number; + expiresAt: number; + uri: string; +} + +const exportsByToken = new Map(); + +function artifactResourceUri(token: string): string { + return `artifact://devspace/${token}`; +} + +function artifactMimeType(name: string): string { + return MIME_TYPES.get(extname(name).toLowerCase()) ?? "application/octet-stream"; +} + +function isTextMimeType(value: string): boolean { + const mimeType = value.split(";", 1)[0]?.trim().toLowerCase() ?? ""; + return mimeType.startsWith("text/") + || mimeType === "application/json" + || mimeType.endsWith("+json") + || mimeType === "application/xml" + || mimeType.endsWith("+xml") + || mimeType === "application/javascript" + || mimeType === "application/yaml"; +} + +function isInsideRoot(path: string, root: string): boolean { + const relationship = relative(root, path); + return relationship === "" || ( + !isAbsolute(relationship) + && relationship !== ".." + && !relationship.startsWith(`..${sep}`) + ); +} + +function expireArtifact(artifact: ExportedArtifact): void { + if (artifact.expired) return; + artifact.expired = true; + if (artifact.timer) clearTimeout(artifact.timer); + artifact.timer = undefined; + exportsByToken.delete(artifact.token); + if (artifact.activeReads === 0) void artifact.handle.close().catch(() => undefined); +} + +function lookupArtifact(token: string): ExportedArtifact | undefined { + if (!TOKEN_PATTERN.test(token)) return undefined; + const artifact = exportsByToken.get(token); + if (!artifact) return undefined; + if (Date.now() >= artifact.expiresAtMs) { + expireArtifact(artifact); + return undefined; + } + return artifact; +} + +function retainArtifact(artifact: ExportedArtifact): () => void { + artifact.activeReads += 1; + let released = false; + return () => { + if (released) return; + released = true; + artifact.activeReads -= 1; + if (artifact.expired && artifact.activeReads === 0) { + void artifact.handle.close().catch(() => undefined); + } + }; +} + +export async function exportWorkspaceArtifact({ + workspaceRoot, + filePath, + maxFileBytes = ARTIFACT_RESOURCE_MAX_BYTES, + ttlMs = ARTIFACT_EXPORT_TTL_MS, +}: { + workspaceRoot: string; + filePath: string; + maxFileBytes?: number; + ttlMs?: number; +}): Promise { + if (!Number.isSafeInteger(maxFileBytes) || maxFileBytes < 1) { + throw new ArtifactError( + "artifact_limit_invalid", + "Artifact file-size limit must be a positive integer.", + ); + } + if (!Number.isSafeInteger(ttlMs) || ttlMs < 1) { + throw new ArtifactError( + "artifact_export_ttl_invalid", + "Artifact export TTL must be a positive integer.", + ); + } + + for (const artifact of exportsByToken.values()) { + if (Date.now() >= artifact.expiresAtMs) expireArtifact(artifact); + } + if (exportsByToken.size >= MAX_ACTIVE_EXPORTS) { + throw new ArtifactError( + "artifact_export_capacity", + "Too many artifact exports are active. Try again after an existing export expires.", + ); + } + + let canonicalRoot: string; + let canonicalFile: string; + try { + canonicalRoot = await realpath(workspaceRoot); + canonicalFile = await realpath(filePath); + } catch { + throw new ArtifactError( + "artifact_export_source_invalid", + "Artifact export source must be an existing regular file inside the selected workspace.", + ); + } + if (!isInsideRoot(canonicalFile, canonicalRoot) || canonicalFile === canonicalRoot) { + throw new ArtifactError( + "artifact_export_path_escape", + "Artifact export source must resolve to a file inside the selected workspace.", + ); + } + + let handle: FileHandle | undefined; + try { + handle = await open(canonicalFile, fsConstants.O_RDONLY | NO_FOLLOW).catch(() => { + throw new ArtifactError( + "artifact_export_source_invalid", + "Artifact export source must be an existing regular file inside the selected workspace.", + ); + }); + const metadata = await handle.stat(); + const [verifiedPath, pathMetadata] = await Promise.all([ + realpath(canonicalFile), + lstat(canonicalFile), + ]).catch(() => { + throw new ArtifactError( + "artifact_export_source_changed", + "Artifact export source changed while it was being opened.", + ); + }); + if ( + verifiedPath !== canonicalFile + || !isInsideRoot(verifiedPath, canonicalRoot) + || pathMetadata.isSymbolicLink() + || !pathMetadata.isFile() + || pathMetadata.dev !== metadata.dev + || pathMetadata.ino !== metadata.ino + ) { + throw new ArtifactError( + "artifact_export_source_changed", + "Artifact export source changed while it was being opened.", + ); + } + if (!metadata.isFile()) { + throw new ArtifactError( + "artifact_export_source_invalid", + "Artifact export source must be a regular file.", + ); + } + const effectiveMaxFileBytes = Math.min(maxFileBytes, ARTIFACT_RESOURCE_MAX_BYTES); + if (metadata.size > effectiveMaxFileBytes) { + throw new ArtifactError( + "artifact_export_too_large", + "Artifact export exceeds the configured MCP resource materialization limit.", + ); + } + + const token = randomBytes(32).toString("base64url"); + const expiresAtMs = Date.now() + ttlMs; + const name = basename(canonicalFile); + const artifact: ExportedArtifact = { + token, + handle, + name, + mimeType: artifactMimeType(name), + size: metadata.size, + expiresAtMs, + activeReads: 0, + expired: false, + timer: undefined, + }; + artifact.timer = setTimeout(() => expireArtifact(artifact), ttlMs); + artifact.timer.unref?.(); + exportsByToken.set(token, artifact); + handle = undefined; + + return { + name, + mimeType: artifact.mimeType, + size: artifact.size, + expiresAt: Math.floor(expiresAtMs / 1_000), + uri: artifactResourceUri(token), + }; + } finally { + await handle?.close().catch(() => undefined); + } +} + +export async function readExportedArtifactResource(token: string, uri: string) { + const artifact = lookupArtifact(token); + if (!artifact) { + throw new ArtifactError( + "artifact_export_missing", + "Exported artifact is no longer available.", + ); + } + + const release = retainArtifact(artifact); + try { + const chunks: Buffer[] = []; + const stream = artifact.handle.createReadStream({ start: 0, autoClose: false }); + for await (const chunk of stream) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + const bytes = Buffer.concat(chunks); + if (isTextMimeType(artifact.mimeType)) { + return { + contents: [{ + uri, + mimeType: artifact.mimeType, + text: bytes.toString("utf8"), + }], + }; + } + return { + contents: [{ + uri, + mimeType: artifact.mimeType, + blob: bytes.toString("base64"), + }], + }; + } finally { + release(); + } +} + +export function registerArtifactExportTool( + server: McpServer, + { + config, + workspaces, + }: { + config: ServerConfig; + workspaces: WorkspaceRegistry; + }, +): void { + server.registerResource( + "Exported workspace artifact", + new ResourceTemplate("artifact://devspace/{token}", { list: undefined }), + { + description: "Short-lived workspace file exported for MCP host attachment materialization.", + }, + async (uri, variables) => { + const token = typeof variables.token === "string" ? variables.token : ""; + return readExportedArtifactResource(token, uri.toString()); + }, + ); + + server.registerTool( + "export_artifact", + { + title: "Attach workspace file", + description: + "Send one existing workspace file to the MCP host as a native attachment. Use read instead when the goal is only to inspect file contents. The source must resolve inside the selected workspace and is bounded by artifacts.maxFileBytes plus an 8 MiB MCP materialization ceiling.", + inputSchema: { + workspaceId: z.string().min(1).describe( + "Workspace to use. Reuse the current project's workspaceId.", + ), + path: z.string().min(1).describe( + "Path to an existing file inside the selected workspace.", + ), + }, + outputSchema: { + name: z.string(), + mimeType: z.string(), + size: z.number().int().nonnegative(), + }, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: false, + openWorldHint: false, + }, + }, + async (input) => { + const startedAt = performance.now(); + try { + const workspace = workspaces.getWorkspace(input.workspaceId); + const filePath = workspaces.resolvePath(workspace, input.path); + const exported = await exportWorkspaceArtifact({ + workspaceRoot: workspace.root, + filePath, + maxFileBytes: config.artifactMaxFileBytes, + }); + if (config.logging.toolCalls) { + logEvent(config.logging, "info", "artifact_tool_call", { + tool: "export_artifact", + workspaceId: workspace.id, + size: exported.size, + success: true, + durationMs: Math.round(performance.now() - startedAt), + }); + } + const publicResult = { + name: exported.name, + mimeType: exported.mimeType, + size: exported.size, + }; + return { + content: [ + { type: "text" as const, text: JSON.stringify(publicResult) }, + { + type: "resource_link" as const, + uri: exported.uri, + name: exported.name, + mimeType: exported.mimeType, + size: exported.size, + }, + ], + structuredContent: publicResult, + }; + } catch (error) { + if (config.logging.toolCalls) { + logEvent(config.logging, "warn", "artifact_tool_call", { + tool: "export_artifact", + workspaceId: input.workspaceId, + success: false, + errorCode: error instanceof ArtifactError ? error.code : "internal_error", + durationMs: Math.round(performance.now() - startedAt), + }); + } + throw error; + } + }, + ); +} + +export async function shutdownArtifactExports(): Promise { + const artifacts = [...exportsByToken.values()]; + for (const artifact of artifacts) expireArtifact(artifact); + await Promise.all( + artifacts.map(async (artifact) => { + if (artifact.activeReads === 0) await artifact.handle.close().catch(() => undefined); + }), + ); +} + +export const clearExportedArtifactsForTests = shutdownArtifactExports; diff --git a/src/server.ts b/src/server.ts index 9e7ded7fd..b0a53f077 100644 --- a/src/server.ts +++ b/src/server.ts @@ -21,6 +21,10 @@ import { isArtifactDownloadSupportedPlatform, registerArtifactTools, } from "./artifact-tools.js"; +import { + registerArtifactExportTool, + shutdownArtifactExports, +} from "./artifact-export.js"; import { loadConfig, type ServerConfig } from "./config.js"; import { createOpenAIIncomingArtifactAdapter, @@ -97,7 +101,10 @@ function serverInstructions( config: ServerConfig, toolSurface: ToolSurface, ): string { - const artifactInstruction = + const artifactExportInstruction = config.artifactsEnabled + ? " When the user asks to attach or send an existing workspace file back to the MCP host, use export_artifact. Use read instead when the goal is only to inspect file contents." + : ""; + const artifactDownloadInstruction = config.artifactsEnabled && isArtifactDownloadSupportedPlatform() ? " When the user supplies or generates a file that is not present on the DevSpace host, use download_artifact with its native file value, the existing workspace ID, and a suitable relative destination path chosen from the user's request and project structure. The tool refuses to overwrite an existing destination and returns the normalized workspace-relative path. Use normal workspace tools when explicit inspection, replacement, movement, renaming, or deletion is needed. Do not recreate binary files with write/edit calls or place signed URLs, native file objects, base64 content, or invented host paths in shell commands or logs." : ""; @@ -109,7 +116,7 @@ function serverInstructions( const agents = `Follow instructions returned by ${toolNames.openWorkspace}. Before working under a path listed in availableAgentsFiles, use ${toolNames.read} to inspect that instruction file and follow it. `; const common = `Use DevSpace for coding work. Call ${toolNames.openWorkspace} once for each project folder or isolated worktree, then keep using its workspaceId. During continued work in the same project or worktree, do not call ${toolNames.openWorkspace} again. Open another workspace only when changing projects, switching checkout/worktree mode, creating another isolated worktree, or when the current workspaceId is rejected.`; - return `${common} ${toolSurface.instructions({ agents, skills })}${artifactInstruction}${showChangesInstruction}`; + return `${common} ${toolSurface.instructions({ agents, skills })}${artifactExportInstruction}${artifactDownloadInstruction}${showChangesInstruction}`; } function formatVisibleAgent(agent: { @@ -691,12 +698,15 @@ export function createMcpServer( }, ); - if (config.artifactsEnabled && isArtifactDownloadSupportedPlatform()) { - registerArtifactTools(server, { - config, - workspaces, - incomingArtifactAdapters, - }); + if (config.artifactsEnabled) { + registerArtifactExportTool(server, { config, workspaces }); + if (isArtifactDownloadSupportedPlatform()) { + registerArtifactTools(server, { + config, + workspaces, + incomingArtifactAdapters, + }); + } } return server; @@ -931,6 +941,7 @@ export function createServer( const results = await transports.closeAll(); logSessionCloseResults("server_shutdown", results); processSessions.shutdown(); + await shutdownArtifactExports(); oauthProvider.close(); workspaceStore.close?.(); })(); @@ -964,6 +975,7 @@ if (await isMainModule()) { : isArtifactDownloadSupportedPlatform() ? "enabled" : `unsupported on ${process.platform}`; + console.log(`native artifact export: ${config.artifactsEnabled ? "enabled" : "disabled"}`); console.log(`native artifact download: ${artifactDownloadStatus}`); console.log(`subagent providers: ${formatLocalAgentProviderStatusSummary(localAgentProviders)}`); });