Files
open-design/apps/daemon/tests/mcp-resources-workspace-scope.test.ts
Nicholas-XiongandGitHub 27d2f07bb3 Fix workspace-scoped MCP resources/list and resources/read (#6770) (#6773)
* Fix workspace-scoped MCP resources/read and resources/list (#6770)

The MCP `resources/list` and `resources/read` handlers were calling
`/api/skills` and `/api/design-systems` without workspace headers, while
the project/run tools (after #6569) all resolve the signed-in workspace
and forward `x-od-workspace-id` / `x-od-workspace-member-id`.

This meant an MCP client listing resources only saw the NO-SCOPE catalog:
any Personal design system that the workspace actually owned was filtered
out, and reading `od://design-systems/<personal-id>/DESIGN.md` returned
`404 design system not found` from the daemon even though the binding
exists. The same blind spot affected `/api/skills` listing.

Fix: route both handlers through the same `resolveMcpWorkspaceContext`
that the project/run tools use, and forward the headers on the listing
and read calls. The two handlers are extracted to exported
`_listMcpResources` / `_readMcpResource` helpers so the logic is
reachable from a vitest harness without spinning up a real stdio MCP
server; the original `server.setRequestHandler` bodies now delegate to
those helpers (no behavioral change to the stdio path).

Tests: `apps/daemon/tests/mcp-resources-workspace-scope.test.ts` covers
the header forwarding on both `resources/list` and `resources/read`, the
NO-SCOPE fallback when no workspace resolves, the brief-app resource
fast path that bypasses the daemon, and the unsupported-URI rejection.

* Export OPEN_DESIGN_BRIEF_APP_RESOURCE for tests

The new mcp-resources-workspace-scope test imports this constant to
assert the brief-app resource fast path bypasses the daemon. It is
referenced inside mcp.ts but never exported — typecheck broke with
TS2459.
2026-08-12 07:20:35 +00:00

176 lines
6.9 KiB
TypeScript

import { afterEach, describe, expect, it, vi } from 'vitest';
import {
_listMcpResources,
_readMcpResource,
createMcpDaemonTarget,
OPEN_DESIGN_BRIEF_APP_RESOURCE,
} from '../src/mcp.js';
import { _resetMcpWorkspaceContextCacheForTests } from '../src/mcp-workspace-context.js';
const originalFetch = globalThis.fetch;
const BASE = 'http://127.0.0.1:19001';
const DIRECTORY = {
items: [
{
workspaceId: 'ws-personal',
workspaceName: 'Personal',
workspaceType: 'personal',
workspaceMemberId: 'mem-1',
role: 'owner',
memberStatus: 'active',
lifecycleState: 'active',
},
],
activeWorkspaceId: null,
};
function directoryResponse(): Response {
return new Response(JSON.stringify(DIRECTORY), { status: 200 });
}
function target() {
return createMcpDaemonTarget({ daemonUrl: BASE });
}
afterEach(() => {
_resetMcpWorkspaceContextCacheForTests();
vi.unstubAllGlobals();
globalThis.fetch = originalFetch;
});
describe('MCP workspace-scoped resource handlers (#6770)', () => {
it('list_resources forwards workspace headers on /api/skills and /api/design-systems', async () => {
const calls: Array<{ url: string; init?: RequestInit | undefined }> = [];
const fetchMock = vi.fn(async (url: string, init?: RequestInit) => {
calls.push({ url, init });
if (url.endsWith('/api/workspace/directory')) return directoryResponse();
if (url.endsWith('/api/skills')) {
return new Response(
JSON.stringify({ skills: [{ id: 'deck', name: 'Deck', description: 'Build a deck.' }] }),
{ status: 200 },
);
}
if (url.endsWith('/api/design-systems')) {
return new Response(
JSON.stringify({
designSystems: [
{
id: 'brand-1',
title: 'Personal Brand',
summary: 'Owned by the personal workspace.',
},
],
}),
{ status: 200 },
);
}
return new Response(JSON.stringify({}), { status: 200 });
});
vi.stubGlobal('fetch', fetchMock);
const result = await _listMcpResources(target());
// directory bootstrap
expect(calls.some((c) => c.url.endsWith('/api/workspace/directory'))).toBe(true);
// both listing calls were made
const skillCall = calls.find((c) => c.url.endsWith('/api/skills'));
const dsCall = calls.find((c) => c.url.endsWith('/api/design-systems'));
expect(skillCall).toBeTruthy();
expect(dsCall).toBeTruthy();
// both carry the workspace headers — this is the regression from #6770:
// before the fix they were headerless and the daemon returned the NO-SCOPE
// catalog, hiding claimed Personal design systems from the MCP client.
expect((skillCall?.init?.headers as Record<string, string>)['x-od-workspace-id']).toBe('ws-personal');
expect((skillCall?.init?.headers as Record<string, string>)['x-od-workspace-member-id']).toBe('mem-1');
expect((dsCall?.init?.headers as Record<string, string>)['x-od-workspace-id']).toBe('ws-personal');
expect((dsCall?.init?.headers as Record<string, string>)['x-od-workspace-member-id']).toBe('mem-1');
// the personal design system actually shows up
const uris = result.resources.map((r) => r.uri);
expect(uris).toContain('od://skills/deck/SKILL.md');
expect(uris).toContain('od://design-systems/brand-1/DESIGN.md');
});
it('list_resources falls back to headerless behavior when no workspace context resolves', async () => {
const calls: Array<{ url: string; init?: RequestInit | undefined }> = [];
const fetchMock = vi.fn(async (url: string, init?: RequestInit) => {
calls.push({ url, init });
if (url.endsWith('/api/workspace/directory')) {
// No active membership — non-vela fallback.
return new Response(JSON.stringify({ items: [], activeWorkspaceId: null }), { status: 200 });
}
if (url.endsWith('/api/skills')) {
return new Response(JSON.stringify({ skills: [] }), { status: 200 });
}
if (url.endsWith('/api/design-systems')) {
return new Response(JSON.stringify({ designSystems: [] }), { status: 200 });
}
return new Response(JSON.stringify({}), { status: 200 });
});
vi.stubGlobal('fetch', fetchMock);
const result = await _listMcpResources(target());
const skillCall = calls.find((c) => c.url.endsWith('/api/skills'));
const dsCall = calls.find((c) => c.url.endsWith('/api/design-systems'));
expect(skillCall?.init?.headers).toBeUndefined();
expect(dsCall?.init?.headers).toBeUndefined();
// Built-in resources still listed.
expect(result.resources.some((r) => r.uri === OPEN_DESIGN_BRIEF_APP_RESOURCE)).toBe(true);
expect(result.resources.some((r) => r.uri === 'od://focus/active')).toBe(true);
});
it('read_resource forwards workspace headers when reading a Personal design system', async () => {
const calls: Array<{ url: string; init?: RequestInit | undefined }> = [];
const fetchMock = vi.fn(async (url: string, init?: RequestInit) => {
calls.push({ url, init });
if (url.endsWith('/api/workspace/directory')) return directoryResponse();
if (url.match(/\/api\/design-systems\/[^/]+$/u)) {
return new Response(
JSON.stringify({
designSystem: {
id: 'brand-1',
body: 'palette: indigo/violet\nfonts: Inter\nvoice: crisp',
},
}),
{ status: 200 },
);
}
return new Response(JSON.stringify({}), { status: 200 });
});
vi.stubGlobal('fetch', fetchMock);
const result = await _readMcpResource(target(), 'od://design-systems/brand-1/DESIGN.md');
const read = calls.find((c) => c.url.endsWith('/api/design-systems/brand-1'));
expect(read).toBeTruthy();
// Without this header, the daemon returns `404 design system not found`
// for a Personal design system the workspace actually owns (#6770).
expect((read?.init?.headers as Record<string, string>)['x-od-workspace-id']).toBe('ws-personal');
expect((read?.init?.headers as Record<string, string>)['x-od-workspace-member-id']).toBe('mem-1');
expect(result.contents[0]?.mimeType).toBe('text/markdown');
expect(result.contents[0]?.text).toContain('palette: indigo/violet');
});
it('read_resource still serves the brief app resource without touching the daemon', async () => {
const fetchMock = vi.fn(async () => new Response('should-not-be-called', { status: 500 }));
vi.stubGlobal('fetch', fetchMock);
const result = await _readMcpResource(target(), OPEN_DESIGN_BRIEF_APP_RESOURCE);
expect(fetchMock).not.toHaveBeenCalled();
expect(result.contents[0]?.mimeType).toBe('text/html;profile=mcp-app');
});
it('read_resource rejects unsupported URIs', async () => {
vi.stubGlobal('fetch', vi.fn(async () => new Response('', { status: 200 })));
await expect(
_readMcpResource(target(), 'od://unknown/foo/bar'),
).rejects.toThrow(/unsupported resource URI/u);
});
});