Files
open-design/apps/daemon/tests/mcp-create-artifact.test.ts
T
YOMXXXandGitHub ee7422fce0 fix(daemon): scope MCP project tools to the signed-in workspace (#6595)
* fix(daemon): scope MCP project tools to the signed-in workspace

After 0.18.0's workspace isolation, projects are lazy-adopted into the
signed-in user's workspace, so the NO-SCOPE catalog (GET /api/projects)
returns empty to a headerless caller and bound-project reads 400 with
WORKSPACE_CONTEXT_REQUIRED. The MCP stdio bridge sent no x-od-workspace-*
headers, so every MCP integration (Claude Code, Codex, Cursor, Hermes)
saw an empty project list after upgrading (#6569).

The bridge now resolves the daemon's signed-in workspace once via the
headerless GET /api/workspace/directory (personal-then-first selection,
mirroring the daemon's selectDefaultCandidate) and sends the workspace
pair on all project/run calls:

- list_projects and name resolution use the workspace-scoped catalog
- get_project/get_file/list_files/search_files/get_artifact/write_file/
  delete_file/delete_project/create_project/create_artifact/start_run/
  get_run/cancel_run carry x-od-workspace-id + x-od-workspace-member-id
- create_project retries headerless once when the workspace pair is stale
- non-vela / signed-out / directory-outage falls back to the previous
  headerless behavior (unbound projects only)

* fix(daemon): satisfy exactOptionalPropertyTypes in MCP workspace test fixtures
2026-08-07 09:50:49 +00:00

153 lines
6.0 KiB
TypeScript

import { afterEach, describe, expect, it, vi } from 'vitest';
import { handleMcpToolCall } from '../src/mcp.js';
import { _resetMcpWorkspaceContextCacheForTests } from '../src/mcp-workspace-context.js';
const originalFetch = globalThis.fetch;
// Non-vela directory: the bridge falls back to headerless behavior, which is
// what this suite exercised before #6569.
function withDirectory(
fn: (url: string, init?: RequestInit) => Promise<Response>,
): (url: string, init?: RequestInit) => Promise<Response> {
return async (url: string, init?: RequestInit) => {
if (String(url).endsWith('/api/workspace/directory')) {
return new Response(JSON.stringify({ items: [], activeWorkspaceId: null }), { status: 200 });
}
return fn(url, init);
};
}
function firstText(result: { content: Array<{ text: string }> }): string {
const item = result.content[0];
if (!item) throw new Error('expected MCP text content');
return item.text;
}
describe('public MCP create_artifact', () => {
afterEach(() => {
_resetMcpWorkspaceContextCacheForTests();
vi.unstubAllGlobals();
globalThis.fetch = originalFetch;
});
it('resolves project names and posts a non-overwrite artifact request', async () => {
const fetchMock = vi.fn(async (url: string, init?: RequestInit) => {
if (url.endsWith('/api/projects')) {
return new Response(JSON.stringify({ projects: [{ id: 'project-1', name: 'Demo' }] }), { status: 200 });
}
return new Response(
JSON.stringify({
file: {
name: 'deck.html',
artifactManifest: { kind: 'deck', renderer: 'deck-html', entry: 'deck.html' },
},
}),
{ status: 200 },
);
});
vi.stubGlobal('fetch', withDirectory(fetchMock));
const result = await handleMcpToolCall('http://127.0.0.1:17456', 'create_artifact', {
project: 'Demo',
name: 'deck.html',
content: '<!doctype html><h1>Deck</h1>',
});
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(fetchMock.mock.calls[1]?.[0]).toBe('http://127.0.0.1:17456/api/projects/project-1/files');
expect(JSON.parse(String(fetchMock.mock.calls[1]?.[1]?.body))).toEqual({
name: 'deck.html',
content: '<!doctype html><h1>Deck</h1>',
encoding: 'utf8',
artifact: true,
overwrite: false,
});
expect(JSON.parse(firstText(result))).toMatchObject({
file: {
name: 'deck.html',
artifactManifest: { kind: 'deck', renderer: 'deck-html', entry: 'deck.html' },
},
});
});
it('uses the active project when project is omitted', async () => {
const fetchMock = vi.fn(async (url: string) => {
if (url.endsWith('/api/active')) {
return new Response(JSON.stringify({ active: true, projectId: 'active-1', projectName: 'Active', fileName: null }), { status: 200 });
}
return new Response(JSON.stringify({ file: { name: 'index.html' } }), { status: 200 });
});
vi.stubGlobal('fetch', withDirectory(fetchMock));
const result = await handleMcpToolCall('http://127.0.0.1:17456', 'create_artifact', {
name: 'index.html',
content: '<!doctype html><h1>Active</h1>',
});
expect(fetchMock.mock.calls[1]?.[0]).toBe('http://127.0.0.1:17456/api/projects/active-1/files');
expect(JSON.parse(firstText(result))).toMatchObject({
usedActiveContext: { projectId: 'active-1', projectName: 'Active' },
});
});
it('resolves non-UUID project ids and posts a non-overwrite artifact request', async () => {
const fetchMock = vi.fn(async (url: string) => {
if (url.endsWith('/api/projects')) {
return new Response(JSON.stringify({ projects: [{ id: 'custom-project-id', name: 'Demo' }] }), { status: 200 });
}
return new Response(JSON.stringify({ file: { name: 'index.html' } }), { status: 200 });
});
vi.stubGlobal('fetch', withDirectory(fetchMock));
const result = await handleMcpToolCall('http://127.0.0.1:17457', 'create_artifact', {
project: 'custom-project-id',
name: 'index.html',
content: '<!doctype html>',
});
expect(fetchMock.mock.calls[1]?.[0]).toBe('http://127.0.0.1:17457/api/projects/custom-project-id/files');
expect(JSON.parse(firstText(result))).toMatchObject({ file: { name: 'index.html' } });
});
it('rejects missing required fields before posting the artifact', async () => {
const fetchMock = vi.fn(async (url: string) => {
if (url.endsWith('/api/projects')) {
return new Response(JSON.stringify({ projects: [{ id: 'project-1', name: 'Demo' }] }), { status: 200 });
}
return new Response(JSON.stringify({ file: { name: 'unused.html' } }), { status: 200 });
});
vi.stubGlobal('fetch', withDirectory(fetchMock));
const result = await handleMcpToolCall('http://127.0.0.1:17456', 'create_artifact', {
project: 'Demo',
content: '<!doctype html>',
});
expect(result).toMatchObject({ isError: true });
expect(firstText(result)).toContain('name is required');
expect(fetchMock.mock.calls.some((call) => String(call[0]).includes('/files'))).toBe(false);
});
it('rejects non-object artifact manifests before posting the artifact', async () => {
const fetchMock = vi.fn(async (url: string) => {
if (url.endsWith('/api/projects')) {
return new Response(JSON.stringify({ projects: [{ id: 'project-1', name: 'Demo' }] }), { status: 200 });
}
return new Response(JSON.stringify({ file: { name: 'unused.html' } }), { status: 200 });
});
vi.stubGlobal('fetch', withDirectory(fetchMock));
const result = await handleMcpToolCall('http://127.0.0.1:17456', 'create_artifact', {
project: 'Demo',
name: 'index.html',
content: '<!doctype html>',
artifactManifest: 'not-a-manifest',
});
expect(result).toMatchObject({ isError: true });
expect(firstText(result)).toContain('artifactManifest must be an object');
expect(fetchMock.mock.calls.some((call) => String(call[0]).includes('/files'))).toBe(false);
});
});