Files
open-design/apps/daemon/tests/mcp-get-project.test.ts
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

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,
});
});
});