mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
* 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
153 lines
6.0 KiB
TypeScript
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);
|
|
});
|
|
});
|