Skip to content

Commit d7621e8

Browse files
committed
feat(mcp): auto-detect project root via roots/list capability
- Add automatic project root detection using MCP protocol's roots/list feature. - This allows the MCP server to automatically detect the correct project directory from clients like Claude Code, eliminating the need for manual CODINGBUDDY_PROJECT_ROOT configuration. close #234
1 parent c74a7d3 commit d7621e8

2 files changed

Lines changed: 305 additions & 1 deletion

File tree

apps/mcp-server/src/mcp/mcp.service.spec.ts

Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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
4550
vi.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

200216
const 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
// ============================================================================

apps/mcp-server/src/mcp/mcp.service.ts

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Injectable, OnModuleInit, Inject } from '@nestjs/common';
1+
import { Injectable, OnModuleInit, Inject, Logger } from '@nestjs/common';
22
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
33
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
44
import {
@@ -17,9 +17,11 @@ import { getPackageVersion } from '../shared/version.utils';
1717
import type { CodingBuddyConfig } from '../config/config.schema';
1818
import type { ToolHandler } from './handlers';
1919
import { TOOL_HANDLERS } from './handlers';
20+
import { fileURLToPath } from 'url';
2021

2122
@Injectable()
2223
export class McpService implements OnModuleInit {
24+
private readonly logger = new Logger(McpService.name);
2325
private server: Server;
2426

2527
constructor(
@@ -52,6 +54,76 @@ export class McpService implements OnModuleInit {
5254
const transport = new StdioServerTransport();
5355
await this.server.connect(transport);
5456
// Note: Do not log here - stdout is reserved for MCP JSON-RPC messages
57+
58+
// Try to get project root from client's roots capability
59+
// This runs after connection is established
60+
await this.updateProjectRootFromClient();
61+
}
62+
63+
/** Timeout for listRoots() request in milliseconds */
64+
private static readonly LIST_ROOTS_TIMEOUT_MS = 5000;
65+
66+
/**
67+
* Update project root from client's roots capability.
68+
* This allows the MCP server to automatically detect the project directory
69+
* when CODINGBUDDY_PROJECT_ROOT env var is not set.
70+
*
71+
* Priority: env var > roots/list > findProjectRoot()
72+
*/
73+
private async updateProjectRootFromClient(): Promise<void> {
74+
// Skip if CODINGBUDDY_PROJECT_ROOT is already set
75+
if (process.env.CODINGBUDDY_PROJECT_ROOT) {
76+
this.logger.debug(
77+
'CODINGBUDDY_PROJECT_ROOT already set, skipping roots/list request',
78+
);
79+
return;
80+
}
81+
82+
try {
83+
// Request roots from client with timeout to prevent startup delays
84+
const result = await this.listRootsWithTimeout();
85+
86+
if (result.roots && result.roots.length > 0) {
87+
// Use the first root as the project root
88+
// MCP clients typically list roots in priority order
89+
const rootUri = result.roots[0].uri;
90+
91+
// Validate URI scheme before conversion
92+
if (!rootUri.startsWith('file://')) {
93+
this.logger.debug(`Ignoring non-file URI from client: ${rootUri}`);
94+
return;
95+
}
96+
97+
// Convert file:// URI to filesystem path
98+
// e.g., file:///Users/jeremy/workspace/myproject -> /Users/jeremy/workspace/myproject
99+
const projectRoot = fileURLToPath(rootUri);
100+
101+
this.logger.log(`Detected project root from client: ${projectRoot}`);
102+
103+
// Update config service with the new project root
104+
await this.configService.setProjectRootAndReload(projectRoot);
105+
}
106+
} catch (error) {
107+
// Client may not support roots capability - this is fine, fall back to default behavior
108+
this.logger.debug(
109+
`Could not get roots from client: ${error instanceof Error ? error.message : 'Unknown error'}`,
110+
);
111+
}
112+
}
113+
114+
/**
115+
* Request roots from client with timeout.
116+
* Prevents startup delays if client is slow or unresponsive.
117+
*/
118+
private async listRootsWithTimeout(): Promise<{ roots?: { uri: string }[] }> {
119+
const timeoutPromise = new Promise<never>((_, reject) => {
120+
setTimeout(
121+
() => reject(new Error('listRoots timeout')),
122+
McpService.LIST_ROOTS_TIMEOUT_MS,
123+
);
124+
});
125+
126+
return Promise.race([this.server.listRoots(), timeoutPromise]);
55127
}
56128

57129
getServer() {

0 commit comments

Comments
 (0)