diff --git a/src/cli/bds-mcp.ts b/src/cli/bds-mcp.ts index b5ca864..1129e95 100644 --- a/src/cli/bds-mcp.ts +++ b/src/cli/bds-mcp.ts @@ -74,6 +74,19 @@ async function main(): Promise { const templateEngine = new TemplateEngine(notifier); const metaEngine = new MetaEngine(); + // 3b. Point every engine at the active project so queries/mutations + // target the correct project instead of the hardcoded 'default'. + const dataDir = activeProject.dataPath + ?? path.join(userData, 'projects', activeProject.id); + + postEngine.setProjectContext(activeProject.id, dataDir); + mediaEngine.setProjectContext(activeProject.id, dataDir, dataDir); + postMediaEngine.setProjectContext(activeProject.id); + tagEngine.setProjectContext(activeProject.id, dataDir); + scriptEngine.setProjectContext(activeProject.id, dataDir); + templateEngine.setProjectContext(activeProject.id, dataDir); + metaEngine.setProjectContext(activeProject.id, dataDir); + // 4. Create the MCP server with an 8-hour proposal TTL (CLI sessions can // last overnight). const mcpServer = new MCPServer( diff --git a/tests/cli/bds-mcp-project-context.test.ts b/tests/cli/bds-mcp-project-context.test.ts new file mode 100644 index 0000000..4db3ea6 --- /dev/null +++ b/tests/cli/bds-mcp-project-context.test.ts @@ -0,0 +1,208 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +/** + * Verifies that the MCP standalone CLI calls setProjectContext() on every + * engine after resolving the active project from the database. + */ + +// ── Spies that each mock-engine instance populates ────────────────────────── + +let postEngineSetProjectContext: ReturnType; +let mediaEngineSetProjectContext: ReturnType; +let postMediaEngineSetProjectContext: ReturnType; +let tagEngineSetProjectContext: ReturnType; +let scriptEngineSetProjectContext: ReturnType; +let templateEngineSetProjectContext: ReturnType; +let metaEngineSetProjectContext: ReturnType; + +function resetSpies(): void { + postEngineSetProjectContext = vi.fn(); + mediaEngineSetProjectContext = vi.fn(); + postMediaEngineSetProjectContext = vi.fn(); + tagEngineSetProjectContext = vi.fn(); + scriptEngineSetProjectContext = vi.fn(); + templateEngineSetProjectContext = vi.fn(); + metaEngineSetProjectContext = vi.fn(); +} + +// ── Active project data ───────────────────────────────────────────────────── + +const ACTIVE_PROJECT_CUSTOM_PATH = { + id: 'my-blog', + name: 'My Blog', + slug: 'my-blog', + description: 'Test project', + dataPath: '/custom/data/path', + createdAt: new Date(), + updatedAt: new Date(), + isActive: true, +}; + +const ACTIVE_PROJECT_DEFAULT_PATH = { + id: 'internal-project', + name: 'Internal Project', + slug: 'internal-project', + description: null, + dataPath: null, + createdAt: new Date(), + updatedAt: new Date(), + isActive: true, +}; + +// ── Chainable query mock ──────────────────────────────────────────────────── + +let mockActiveProject: typeof ACTIVE_PROJECT_CUSTOM_PATH | typeof ACTIVE_PROJECT_DEFAULT_PATH = ACTIVE_PROJECT_CUSTOM_PATH; + +function makeMockLocalDb() { + return { + select: vi.fn().mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + get: vi.fn().mockResolvedValue(mockActiveProject), + }), + }), + }), + }; +} + +// ── Hoisted mocks ─────────────────────────────────────────────────────────── + +vi.mock('../../src/cli/platform', () => ({ + platformConfigPath: vi.fn(() => '/tmp/mock-userdata'), +})); + +vi.mock('../../src/main/database/connection', () => { + const mockDb = { + initializeLocal: vi.fn().mockResolvedValue(undefined), + getLocal: vi.fn(() => makeMockLocalDb()), + close: vi.fn().mockResolvedValue(undefined), + }; + return { + initDatabase: vi.fn(() => mockDb), + getDatabase: vi.fn(() => mockDb), + DatabaseConnection: vi.fn(), + }; +}); + +vi.mock('../../src/main/database/schema', () => ({ + projects: { isActive: 'isActive' }, +})); + +vi.mock('drizzle-orm', () => ({ + eq: vi.fn((_col: unknown, _val: unknown) => 'isActive=true'), +})); + +vi.mock('../../src/main/engine/CliNotifier', () => ({ + DbNotifier: vi.fn().mockImplementation(function(this: Record) { return this; }), +})); + +vi.mock('../../src/main/engine/PostEngine', () => ({ + PostEngine: vi.fn().mockImplementation(function(this: Record) { + this.setProjectContext = (...args: unknown[]) => postEngineSetProjectContext(...args); + return this; + }), +})); + +vi.mock('../../src/main/engine/MediaEngine', () => ({ + MediaEngine: vi.fn().mockImplementation(function(this: Record) { + this.setProjectContext = (...args: unknown[]) => mediaEngineSetProjectContext(...args); + return this; + }), +})); + +vi.mock('../../src/main/engine/PostMediaEngine', () => ({ + PostMediaEngine: vi.fn().mockImplementation(function(this: Record) { + this.setProjectContext = (...args: unknown[]) => postMediaEngineSetProjectContext(...args); + return this; + }), +})); + +vi.mock('../../src/main/engine/TagEngine', () => ({ + TagEngine: vi.fn().mockImplementation(function(this: Record) { + this.setProjectContext = (...args: unknown[]) => tagEngineSetProjectContext(...args); + return this; + }), +})); + +vi.mock('../../src/main/engine/ScriptEngine', () => ({ + ScriptEngine: vi.fn().mockImplementation(function(this: Record) { + this.setProjectContext = (...args: unknown[]) => scriptEngineSetProjectContext(...args); + return this; + }), +})); + +vi.mock('../../src/main/engine/TemplateEngine', () => ({ + TemplateEngine: vi.fn().mockImplementation(function(this: Record) { + this.setProjectContext = (...args: unknown[]) => templateEngineSetProjectContext(...args); + return this; + }), +})); + +vi.mock('../../src/main/engine/MetaEngine', () => ({ + MetaEngine: vi.fn().mockImplementation(function(this: Record) { + this.setProjectContext = (...args: unknown[]) => metaEngineSetProjectContext(...args); + return this; + }), +})); + +vi.mock('../../src/main/engine/MCPServer', () => ({ + MCPServer: vi.fn().mockImplementation(function(this: Record) { + this.startCli = vi.fn().mockResolvedValue(undefined); + this.cleanup = vi.fn().mockResolvedValue(undefined); + return this; + }), +})); + +// ── Tests ─────────────────────────────────────────────────────────────────── + +describe('bds-mcp project context initialisation', () => { + const originalExit = process.exit; + + beforeEach(() => { + resetSpies(); + // Prevent process.exit from actually killing the test runner + process.exit = vi.fn() as never; + }); + + afterEach(() => { + process.exit = originalExit; + vi.restoreAllMocks(); + vi.resetModules(); + }); + + it('calls setProjectContext on all engines with the active project (custom dataPath)', async () => { + mockActiveProject = ACTIVE_PROJECT_CUSTOM_PATH; + + await import('../../src/cli/bds-mcp'); + // Give main() a tick to complete + await new Promise((r) => setTimeout(r, 100)); + + const expectedProjectId = 'my-blog'; + const expectedDataDir = '/custom/data/path'; + + expect(postEngineSetProjectContext).toHaveBeenCalledWith(expectedProjectId, expectedDataDir); + expect(mediaEngineSetProjectContext).toHaveBeenCalledWith(expectedProjectId, expectedDataDir, expectedDataDir); + expect(postMediaEngineSetProjectContext).toHaveBeenCalledWith(expectedProjectId); + expect(tagEngineSetProjectContext).toHaveBeenCalledWith(expectedProjectId, expectedDataDir); + expect(scriptEngineSetProjectContext).toHaveBeenCalledWith(expectedProjectId, expectedDataDir); + expect(templateEngineSetProjectContext).toHaveBeenCalledWith(expectedProjectId, expectedDataDir); + expect(metaEngineSetProjectContext).toHaveBeenCalledWith(expectedProjectId, expectedDataDir); + }); + + it('uses internal userData path when project has no custom dataPath', async () => { + mockActiveProject = ACTIVE_PROJECT_DEFAULT_PATH; + + await import('../../src/cli/bds-mcp'); + await new Promise((r) => setTimeout(r, 100)); + + const expectedDataDir = '/tmp/mock-userdata/projects/internal-project'; + + expect(postEngineSetProjectContext).toHaveBeenCalledWith('internal-project', expectedDataDir); + expect(mediaEngineSetProjectContext).toHaveBeenCalledWith('internal-project', expectedDataDir, expectedDataDir); + expect(postMediaEngineSetProjectContext).toHaveBeenCalledWith('internal-project'); + expect(tagEngineSetProjectContext).toHaveBeenCalledWith('internal-project', expectedDataDir); + expect(scriptEngineSetProjectContext).toHaveBeenCalledWith('internal-project', expectedDataDir); + expect(templateEngineSetProjectContext).toHaveBeenCalledWith('internal-project', expectedDataDir); + expect(metaEngineSetProjectContext).toHaveBeenCalledWith('internal-project', expectedDataDir); + }); +});