Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions docs/artifact-exchange.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,19 +8,20 @@ directly into an open workspace. Enable the tool with

```text
open_workspace
-> download_artifact({ file, workspaceId, path })
-> download_artifact({ file, workspace_id, path })
-> { path }
```

1. Open the project with `open_workspace`.
2. Pass the host-provided native `file`, the returned `workspaceId`, and an
unused workspace-relative `path` to `download_artifact`.
2. Pass the host-provided native `file`, the returned `workspaceId` as
`workspace_id`, and an unused workspace-relative `path` to
`download_artifact`.
3. Use the returned path with the ordinary DevSpace filesystem tools.

```text
download_artifact({
file: <native file value supplied by the MCP host>,
workspaceId: "ws_123",
workspace_id: "ws_123",
path: "public/images/generated-image.png"
})
```
Expand Down
16 changes: 8 additions & 8 deletions docs/chatgpt-coding-workflow.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,17 +15,17 @@ ChatGPT should call `open_workspace` once for a project folder:
```

The result includes a `workspaceId`. All later file, search, edit, show-changes,
and shell calls should reuse that same `workspaceId`.
and shell calls should pass that same value as `workspace_id`.

ChatGPT may support automatic checkout recovery through optional host
conversation metadata. This is an OpenAI-host adapter detail, not a standard MCP
conversation field. When that optional context is available, opening the same
checkout project again in the same conversation can continue in the existing
workspace, and the context already provided for that reused checkout is not
repeated. The portable workflow remains the same: keep using the `workspaceId`
returned by `open_workspace` for later operations. Hosts without supported
conversation context receive a normal new workspace and continue with that
explicit `workspaceId` workflow.
returned by `open_workspace` as `workspace_id` for later operations. Hosts
without supported conversation context receive a normal new workspace and
continue with that explicit workspace ID workflow.
The model receives actionable workspace instructions; automatic-reuse
bookkeeping is not a model-facing choice.

Expand Down Expand Up @@ -78,12 +78,12 @@ Managed worktrees are created under:
```

Worktree mode requires a Git repository with at least one commit. It starts from
`HEAD` unless `baseRef` is provided.
`HEAD` unless `base_ref` is provided.

Each worktree-mode call creates a new managed worktree and returns a new
`workspaceId`. Reuse that ID for work inside that worktree; call
`open_workspace` in worktree mode again only when another isolated worktree is
actually required.
`workspaceId`. Reuse that ID as `workspace_id` for work inside that worktree;
call `open_workspace` in worktree mode again only when another isolated worktree
is actually required.

Uncommitted source checkout changes are not copied into the managed worktree.
DevSpace reports when the source checkout was dirty so the model can decide how
Expand Down
7 changes: 4 additions & 3 deletions docs/gotchas.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,8 +149,9 @@ the same project in that conversation; repeated opens reuse the `workspaceId`
and do not repeat context already provided for that reused checkout. Worktree
mode always creates a new isolated workspace with its own complete context.
Hosts without supported conversation metadata receive a normal new workspace.
In all cases, continue passing the `workspaceId` returned by `open_workspace` to
later tools. Other MCP hosts use this explicit workspace workflow as well.
In all cases, continue passing the `workspaceId` returned by `open_workspace` as
`workspace_id` to later tools. Other MCP hosts use this explicit workspace
workflow as well.

To review work, call `show_changes` once after the final related file change. It
shows the combined changes and advances the review point automatically.
Expand Down Expand Up @@ -186,7 +187,7 @@ Worktree mode requires:
- Git installed
- the path is inside a Git repository
- the repository has at least one commit
- the requested `baseRef` resolves to a commit
- the requested `base_ref` resolves to a commit

For a new repository, create the first commit or use checkout mode.

Expand Down
6 changes: 3 additions & 3 deletions docs/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,9 +96,9 @@ sessions.

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.
`workspace_id` containing 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,
Expand Down
4 changes: 2 additions & 2 deletions src/artifact-download.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ function testOneToolContract(): void {
const descriptor = registered.get("download_artifact")?.descriptor;
assert.ok(descriptor);
assert.deepEqual(descriptor._meta, { "openai/fileParams": ["file"] });
assert.deepEqual(Object.keys(descriptor.inputSchema as object).sort(), ["file", "path", "workspaceId"]);
assert.deepEqual(Object.keys(descriptor.inputSchema as object).sort(), ["file", "path", "workspace_id"]);
assert.deepEqual(Object.keys(descriptor.outputSchema as object), ["path"]);
assert.equal((descriptor.annotations as { destructiveHint?: boolean }).destructiveHint, false);

Expand Down Expand Up @@ -353,7 +353,7 @@ function testLogRedaction(): void {
file_name: "generated.png",
authorization: "Bearer log-secret",
},
workspaceId: "ws_secret",
workspace_id: "ws_secret",
path: "private/generated.png",
});
const serialized = JSON.stringify(fields);
Expand Down
8 changes: 4 additions & 4 deletions src/artifact-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,8 +103,8 @@ export function registerArtifactTools(
file: openAIFileReferenceInputSchema.describe(
"Native file value authorized and supplied by the MCP host.",
),
workspaceId: z.string().min(1).describe(
"Workspace to use. Reuse the current project's workspaceId.",
workspace_id: z.string().min(1).describe(
"Workspace to use. Pass the workspaceId returned by open_workspace as workspace_id.",
),
path: z.string().min(1).describe(
"Relative destination path inside the selected workspace. The destination must not already exist.",
Expand All @@ -117,7 +117,7 @@ export function registerArtifactTools(
annotations: ARTIFACT_WRITE_ANNOTATIONS,
},
async (input) => executeArtifactTool(config, input, async () => {
const workspace = workspaces.getWorkspace(input.workspaceId);
const workspace = workspaces.getWorkspace(input.workspace_id);
const downloaded = await downloadIncomingArtifact({
registry: incomingRegistry,
workspaceId: workspace.id,
Expand Down Expand Up @@ -292,7 +292,7 @@ export function artifactToolLogFields(
fileProvided: input.file !== undefined,
fileReferenceShape: describeIncomingArtifactValue(input.file),
downloadUrlHostname: incomingFileDownloadHostname(input.file),
workspaceId: input.workspaceId,
workspaceId: input.workspace_id,
path: input.path,
};
}
Expand Down
130 changes: 123 additions & 7 deletions src/server.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import { createHash } from "node:crypto";
import { access, mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
import { access, mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test, { type TestContext } from "node:test";
Expand Down Expand Up @@ -49,6 +49,97 @@ test("tool modes expose the expected host-facing tool surface", async (t) => {
}
});

test("model-facing tool inputs use snake_case recursively", async (t) => {
for (const toolMode of ["claude", "codex"] as const) {
await t.test(toolMode, async (nested) => {
const context = await fixture(nested, { toolMode, uiEnabled: false });
const tools = await context.client.listTools();
const invalidPaths = tools.tools.flatMap((tool) => (
schemaPropertyPaths(tool.inputSchema)
.filter(({ key }) => !/^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$/.test(key))
.map(({ path }) => `${tool.name}.${path}`)
));

assert.deepEqual(invalidPaths, []);
});
}
});

test("Codex process tools accept snake_case session and yield inputs", async (t) => {
const context = await fixture(t, { toolMode: "codex", uiEnabled: false });
const workspaceId = structuredContent(
await callOpen(context.client, context.project, "snake-case-process"),
).workspaceId;
assert.equal(typeof workspaceId, "string");

const started = structuredContent(await context.client.callTool({
name: "exec_command",
arguments: {
workspace_id: workspaceId,
cmd: 'node -e "setTimeout(() => {}, 500)"',
yield_time_ms: 0,
},
}));
assert.equal(started.running, true);
assert.equal(typeof started.sessionId, "number");

const finished = structuredContent(await context.client.callTool({
name: "write_stdin",
arguments: {
workspace_id: workspaceId,
session_id: started.sessionId,
yield_time_ms: 2_000,
},
}));
assert.equal(finished.running, false);
assert.equal(finished.exitCode, 0);
});

test("open_workspace instructions tell models to pass the returned ID as workspace_id", async (t) => {
const context = await fixture(t, { toolMode: "codex", uiEnabled: false });
const first = structuredContent(
await callOpen(context.client, context.project, "snake-case-instructions"),
);
const repeated = structuredContent(
await callOpen(context.client, context.project, "snake-case-instructions"),
);

assert.match(first.instruction as string, /workspace_id/);
assert.match(repeated.instruction as string, /workspace_id/);
});

test("Claude edit and bash tools accept snake_case runtime inputs", async (t) => {
const context = await fixture(t, { toolMode: "claude", uiEnabled: false });
const workspaceId = structuredContent(
await callOpen(context.client, context.project, "snake-case-claude"),
).workspaceId;
assert.equal(typeof workspaceId, "string");

await writeFile(join(context.project, "note.txt"), "before\n");
await mkdir(join(context.project, "nested"));

const edited = await context.client.callTool({
name: "edit",
arguments: {
workspace_id: workspaceId,
path: "note.txt",
edits: [{ old_text: "before", new_text: "after" }],
},
});
assert.equal(edited.isError, undefined);
assert.equal(await readFile(join(context.project, "note.txt"), "utf8"), "after\n");

const shell = structuredContent(await context.client.callTool({
name: "bash",
arguments: {
workspace_id: workspaceId,
command: "pwd",
working_directory: "nested",
},
}));
assert.match(shell.result as string, /nested/i);
});

test("UI metadata is limited to workspace and aggregate review", async (t) => {
for (const uiEnabled of [true, false]) {
await t.test(uiEnabled ? "enabled" : "disabled", async (nested) => {
Expand Down Expand Up @@ -86,7 +177,7 @@ test("show_changes keeps model output compact and preserves the rich review card
await writeFile(join(context.project, "README.md"), "goodbye\n");
const review = await context.client.callTool({
name: "show_changes",
arguments: { workspaceId },
arguments: { workspace_id: workspaceId },
});
const structured = structuredContent(review);
assert.equal((review._meta as Record<string, unknown> | undefined)?.tool, undefined);
Expand Down Expand Up @@ -139,15 +230,15 @@ test("show_changes can reopen a historical review without advancing the checkpoi
await writeFile(join(context.project, "README.md"), "first\n");
const first = structuredContent(await context.client.callTool({
name: "show_changes",
arguments: { workspaceId },
arguments: { workspace_id: workspaceId },
}));
const reviewRef = first.reviewRef;
assert.equal(typeof reviewRef, "string");

await writeFile(join(context.project, "README.md"), "second\n");
const reopened = await context.client.callTool({
name: "show_changes",
arguments: { workspaceId },
arguments: { workspace_id: workspaceId },
_meta: { "devspace/reviewRef": reviewRef },
} as Parameters<Client["callTool"]>[0]);
assert.equal(structuredContent(reopened).reviewRef, reviewRef);
Expand All @@ -158,7 +249,7 @@ test("show_changes can reopen a historical review without advancing the checkpoi

const current = await context.client.callTool({
name: "show_changes",
arguments: { workspaceId },
arguments: { workspace_id: workspaceId },
});
assert.match(
(((responseCard(current).payload as { patch?: string } | undefined)?.patch) ?? ""),
Expand Down Expand Up @@ -434,9 +525,9 @@ test("server shutdown waits for an active MCP tool call", async (t) => {
{
name: "exec_command",
arguments: {
workspaceId,
workspace_id: workspaceId,
cmd: `node -e \"${command}\"`,
yieldTimeMs: 30_000,
yield_time_ms: 30_000,
},
},
);
Expand All @@ -460,6 +551,31 @@ interface ServerFixture {
project: string;
}

function schemaPropertyPaths(
schema: unknown,
prefix = "",
): Array<{ key: string; path: string }> {
if (!schema || typeof schema !== "object") return [];
const record = schema as {
properties?: Record<string, unknown>;
items?: unknown;
anyOf?: unknown[];
oneOf?: unknown[];
allOf?: unknown[];
};
const paths = Object.entries(record.properties ?? {}).flatMap(([key, child]) => {
const path = prefix ? `${prefix}.${key}` : key;
return [{ key, path }, ...schemaPropertyPaths(child, path)];
});
if (record.items) paths.push(...schemaPropertyPaths(record.items, `${prefix}[]`));
for (const variant of [record.anyOf, record.oneOf, record.allOf]) {
for (const child of variant ?? []) {
paths.push(...schemaPropertyPaths(child, prefix));
}
}
return paths;
}

interface HttpServerFixture {
root: string;
localBaseUrl: string;
Expand Down
Loading