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
65 lines
2.1 KiB
TypeScript
65 lines
2.1 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 emptyDirectory(): Response {
|
|
return new Response(JSON.stringify({ items: [], activeWorkspaceId: null }), { status: 200 });
|
|
}
|
|
|
|
function firstJson<T>(result: { content: Array<{ text: string }> }): T {
|
|
const item = result.content[0];
|
|
if (!item) throw new Error('expected MCP text content');
|
|
return JSON.parse(item.text) as T;
|
|
}
|
|
|
|
describe('public MCP get_project', () => {
|
|
afterEach(() => {
|
|
_resetMcpWorkspaceContextCacheForTests();
|
|
vi.unstubAllGlobals();
|
|
globalThis.fetch = originalFetch;
|
|
});
|
|
|
|
it('surfaces the daemon-resolved project directory', async () => {
|
|
const base = 'http://127.0.0.1:19001';
|
|
const projectId = '11111111-1111-1111-1111-111111111111';
|
|
const resolvedDir = '/tmp/open-design/projects/demo';
|
|
const fetchMock = vi.fn(async (url: string) => {
|
|
if (url.endsWith('/api/workspace/directory')) return emptyDirectory();
|
|
if (url.endsWith('/api/mcp/install-info')) {
|
|
return new Response(JSON.stringify({ webBaseUrl: null }), { status: 200 });
|
|
}
|
|
expect(url).toBe(`${base}/api/projects/${projectId}`);
|
|
return new Response(
|
|
JSON.stringify({
|
|
project: {
|
|
id: projectId,
|
|
name: 'Demo',
|
|
metadata: { entryFile: 'index.html', kind: 'prototype' },
|
|
},
|
|
resolvedDir,
|
|
}),
|
|
{ status: 200 },
|
|
);
|
|
});
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
|
|
const result = await handleMcpToolCall(base, 'get_project', {
|
|
project: projectId,
|
|
});
|
|
|
|
expect(fetchMock).toHaveBeenCalledTimes(3);
|
|
expect(firstJson(result)).toMatchObject({
|
|
id: projectId,
|
|
name: 'Demo',
|
|
entryFile: 'index.html',
|
|
kind: 'prototype',
|
|
resolvedDir,
|
|
});
|
|
});
|
|
});
|