@@ -41,6 +41,11 @@ const { handlers } = vi.hoisted(() => ({
4141 handlers : new Map < string , McpHandler > ( ) ,
4242} ) ) ;
4343
44+ // Hoist listRoots mock so it can be controlled per test
45+ const { listRootsMock } = vi . hoisted ( ( ) => ( {
46+ listRootsMock : vi . fn ( ) ,
47+ } ) ) ;
48+
4449// Mock the MCP SDK Server
4550vi . mock ( '@modelcontextprotocol/sdk/server/index.js' , ( ) => ( {
4651 Server : class MockServer {
@@ -60,6 +65,7 @@ vi.mock('@modelcontextprotocol/sdk/server/index.js', () => ({
6065 async connect ( ) {
6166 /* noop */
6267 }
68+ listRoots = listRootsMock ;
6369 } ,
6470} ) ) ;
6571
@@ -195,6 +201,16 @@ const createMockConfigService = (
195201 context : null ,
196202 } ,
197203 } as ProjectConfig ) ,
204+ setProjectRootAndReload : vi . fn ( ) . mockResolvedValue ( {
205+ settings : config ,
206+ ignorePatterns : [ 'node_modules' , '.git' ] ,
207+ contextFiles : [ ] ,
208+ sources : {
209+ config : '/test/codingbuddy.config.js' ,
210+ ignore : null ,
211+ context : null ,
212+ } ,
213+ } as ProjectConfig ) ,
198214} ) ;
199215
200216const createMockConfigDiffService = ( ) : Partial < ConfigDiffService > => ( {
@@ -524,6 +540,9 @@ describe('McpService', () => {
524540
525541 beforeEach ( ( ) => {
526542 handlers . clear ( ) ;
543+ listRootsMock . mockReset ( ) ;
544+ // Default: client doesn't support roots
545+ listRootsMock . mockRejectedValue ( new Error ( 'Client does not support roots' ) ) ;
527546 mockRulesService = createMockRulesService ( ) ;
528547 mockKeywordService = createMockKeywordService ( ) ;
529548 mockConfigService = createMockConfigService ( testConfig ) ;
@@ -1648,6 +1667,219 @@ describe('McpService', () => {
16481667 } ) ;
16491668 } ) ;
16501669
1670+ // ============================================================================
1671+ // updateProjectRootFromClient Tests
1672+ // ============================================================================
1673+
1674+ describe ( 'updateProjectRootFromClient' , ( ) => {
1675+ const originalEnv = process . env . CODINGBUDDY_PROJECT_ROOT ;
1676+
1677+ beforeEach ( ( ) => {
1678+ // Reset env var before each test
1679+ delete process . env . CODINGBUDDY_PROJECT_ROOT ;
1680+ } ) ;
1681+
1682+ afterEach ( ( ) => {
1683+ // Restore original env var
1684+ if ( originalEnv !== undefined ) {
1685+ process . env . CODINGBUDDY_PROJECT_ROOT = originalEnv ;
1686+ } else {
1687+ delete process . env . CODINGBUDDY_PROJECT_ROOT ;
1688+ }
1689+ } ) ;
1690+
1691+ describe ( 'successful project root detection' , ( ) => {
1692+ it ( 'should detect project root from file:// URI' , async ( ) => {
1693+ listRootsMock . mockResolvedValue ( {
1694+ roots : [ { uri : 'file:///Users/test/workspace/myproject' } ] ,
1695+ } ) ;
1696+
1697+ const service = createMcpServiceWithHandlers ( defaultMocks ) ;
1698+ await service . startStdio ( ) ;
1699+
1700+ expect ( mockConfigService . setProjectRootAndReload ) . toHaveBeenCalledWith (
1701+ '/Users/test/workspace/myproject' ,
1702+ ) ;
1703+ } ) ;
1704+
1705+ it ( 'should use the first root when multiple roots are provided' , async ( ) => {
1706+ listRootsMock . mockResolvedValue ( {
1707+ roots : [
1708+ { uri : 'file:///Users/test/workspace/primary' } ,
1709+ { uri : 'file:///Users/test/workspace/secondary' } ,
1710+ ] ,
1711+ } ) ;
1712+
1713+ const service = createMcpServiceWithHandlers ( defaultMocks ) ;
1714+ await service . startStdio ( ) ;
1715+
1716+ expect ( mockConfigService . setProjectRootAndReload ) . toHaveBeenCalledWith (
1717+ '/Users/test/workspace/primary' ,
1718+ ) ;
1719+ } ) ;
1720+
1721+ it ( 'should handle Windows file:// URIs' , async ( ) => {
1722+ listRootsMock . mockResolvedValue ( {
1723+ roots : [ { uri : 'file:///C:/Users/test/workspace/myproject' } ] ,
1724+ } ) ;
1725+
1726+ const service = createMcpServiceWithHandlers ( defaultMocks ) ;
1727+ await service . startStdio ( ) ;
1728+
1729+ // On Windows, fileURLToPath would convert to C:\Users\test\workspace\myproject
1730+ // On Unix, it would convert to /C:/Users/test/workspace/myproject
1731+ expect ( mockConfigService . setProjectRootAndReload ) . toHaveBeenCalled ( ) ;
1732+ } ) ;
1733+ } ) ;
1734+
1735+ describe ( 'URI scheme validation' , ( ) => {
1736+ it ( 'should ignore non-file:// URIs (http://)' , async ( ) => {
1737+ listRootsMock . mockResolvedValue ( {
1738+ roots : [ { uri : 'http://example.com/project' } ] ,
1739+ } ) ;
1740+
1741+ const service = createMcpServiceWithHandlers ( defaultMocks ) ;
1742+ await service . startStdio ( ) ;
1743+
1744+ expect (
1745+ mockConfigService . setProjectRootAndReload ,
1746+ ) . not . toHaveBeenCalled ( ) ;
1747+ } ) ;
1748+
1749+ it ( 'should ignore non-file:// URIs (https://)' , async ( ) => {
1750+ listRootsMock . mockResolvedValue ( {
1751+ roots : [ { uri : 'https://example.com/project' } ] ,
1752+ } ) ;
1753+
1754+ const service = createMcpServiceWithHandlers ( defaultMocks ) ;
1755+ await service . startStdio ( ) ;
1756+
1757+ expect (
1758+ mockConfigService . setProjectRootAndReload ,
1759+ ) . not . toHaveBeenCalled ( ) ;
1760+ } ) ;
1761+
1762+ it ( 'should ignore custom URI schemes' , async ( ) => {
1763+ listRootsMock . mockResolvedValue ( {
1764+ roots : [ { uri : 'vscode://workspace/project' } ] ,
1765+ } ) ;
1766+
1767+ const service = createMcpServiceWithHandlers ( defaultMocks ) ;
1768+ await service . startStdio ( ) ;
1769+
1770+ expect (
1771+ mockConfigService . setProjectRootAndReload ,
1772+ ) . not . toHaveBeenCalled ( ) ;
1773+ } ) ;
1774+ } ) ;
1775+
1776+ describe ( 'empty or missing roots' , ( ) => {
1777+ it ( 'should handle empty roots array gracefully' , async ( ) => {
1778+ listRootsMock . mockResolvedValue ( {
1779+ roots : [ ] ,
1780+ } ) ;
1781+
1782+ const service = createMcpServiceWithHandlers ( defaultMocks ) ;
1783+ await service . startStdio ( ) ;
1784+
1785+ expect (
1786+ mockConfigService . setProjectRootAndReload ,
1787+ ) . not . toHaveBeenCalled ( ) ;
1788+ } ) ;
1789+
1790+ it ( 'should handle undefined roots gracefully' , async ( ) => {
1791+ listRootsMock . mockResolvedValue ( { } ) ;
1792+
1793+ const service = createMcpServiceWithHandlers ( defaultMocks ) ;
1794+ await service . startStdio ( ) ;
1795+
1796+ expect (
1797+ mockConfigService . setProjectRootAndReload ,
1798+ ) . not . toHaveBeenCalled ( ) ;
1799+ } ) ;
1800+ } ) ;
1801+
1802+ describe ( 'environment variable precedence' , ( ) => {
1803+ it ( 'should skip roots/list when CODINGBUDDY_PROJECT_ROOT is set' , async ( ) => {
1804+ process . env . CODINGBUDDY_PROJECT_ROOT = '/custom/project/root' ;
1805+
1806+ listRootsMock . mockResolvedValue ( {
1807+ roots : [ { uri : 'file:///Users/test/workspace/myproject' } ] ,
1808+ } ) ;
1809+
1810+ const service = createMcpServiceWithHandlers ( defaultMocks ) ;
1811+ await service . startStdio ( ) ;
1812+
1813+ // listRoots should not be called when env var is set
1814+ expect ( listRootsMock ) . not . toHaveBeenCalled ( ) ;
1815+ expect (
1816+ mockConfigService . setProjectRootAndReload ,
1817+ ) . not . toHaveBeenCalled ( ) ;
1818+ } ) ;
1819+ } ) ;
1820+
1821+ describe ( 'error handling' , ( ) => {
1822+ it ( 'should handle client not supporting roots capability gracefully' , async ( ) => {
1823+ listRootsMock . mockRejectedValue (
1824+ new Error ( 'Client does not support roots' ) ,
1825+ ) ;
1826+
1827+ const service = createMcpServiceWithHandlers ( defaultMocks ) ;
1828+
1829+ // Should not throw
1830+ await expect ( service . startStdio ( ) ) . resolves . not . toThrow ( ) ;
1831+ expect (
1832+ mockConfigService . setProjectRootAndReload ,
1833+ ) . not . toHaveBeenCalled ( ) ;
1834+ } ) ;
1835+
1836+ it ( 'should handle network errors gracefully' , async ( ) => {
1837+ listRootsMock . mockRejectedValue ( new Error ( 'Network error' ) ) ;
1838+
1839+ const service = createMcpServiceWithHandlers ( defaultMocks ) ;
1840+
1841+ // Should not throw
1842+ await expect ( service . startStdio ( ) ) . resolves . not . toThrow ( ) ;
1843+ expect (
1844+ mockConfigService . setProjectRootAndReload ,
1845+ ) . not . toHaveBeenCalled ( ) ;
1846+ } ) ;
1847+ } ) ;
1848+
1849+ describe ( 'timeout handling' , ( ) => {
1850+ it ( 'should timeout if listRoots takes too long' , async ( ) => {
1851+ // Simulate a slow response that exceeds the timeout
1852+ listRootsMock . mockImplementation (
1853+ ( ) =>
1854+ new Promise ( resolve => {
1855+ // Never resolves within the timeout period
1856+ setTimeout (
1857+ ( ) =>
1858+ resolve ( {
1859+ roots : [ { uri : 'file:///Users/test/workspace/myproject' } ] ,
1860+ } ) ,
1861+ 10000 ,
1862+ ) ;
1863+ } ) ,
1864+ ) ;
1865+
1866+ const service = createMcpServiceWithHandlers ( defaultMocks ) ;
1867+
1868+ // Use a shorter timeout to make the test faster
1869+ // The actual implementation has 5s timeout
1870+ const startTime = Date . now ( ) ;
1871+ await service . startStdio ( ) ;
1872+ const elapsed = Date . now ( ) - startTime ;
1873+
1874+ // Should timeout within reasonable time (5s + small buffer)
1875+ expect ( elapsed ) . toBeLessThan ( 6000 ) ;
1876+ expect (
1877+ mockConfigService . setProjectRootAndReload ,
1878+ ) . not . toHaveBeenCalled ( ) ;
1879+ } , 10000 ) ; // Increase test timeout
1880+ } ) ;
1881+ } ) ;
1882+
16511883 // ============================================================================
16521884 // recommend_skills Tool Tests (RED phase - tests should FAIL)
16531885 // ============================================================================
0 commit comments