Skip to content

Commit 855c315

Browse files
committed
BACK-558 - Resolve MCP project root from client workspace roots
The MCP server froze its project root at startup and only consulted client MCP roots in fallback (uninitialized) mode, so a shared/user-scope server -- or one launched in the main checkout while working in a git worktree -- wrote tasks to the wrong backlog directory. Extend the request-scoped roots discovery from #608 (BACK-434) to the normal (initialized) startup path: unless pinned, the server now follows the client's workspace roots and re-resolves on roots/list_changed. A startupHasProject flag makes a launch-directory project return to itself when the client workspace has no backlog (rather than dropping to init-required); a fallback baseline still reverts to init-required. An explicit --cwd/BACKLOG_CWD pin opts out of roots entirely. The existing upgradeToProject/downgradeToFallback/resolveFromRoots and the #570 constraints are kept intact. Behavior change: the normal/initialized path now issues one roots/list request to follow the client workspace (#608 issued none in normal mode); a pin opts out. Closes #558.
1 parent d6fcf24 commit 855c315

6 files changed

Lines changed: 391 additions & 10 deletions

File tree

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -194,7 +194,7 @@ You can run `backlog init` (even if you already initialized Backlog.md) to set u
194194

195195
</details>
196196

197-
Use the shared `backlog` server name everywherethe MCP server auto-detects whether the current directory is initialized and falls back to `backlog://init-required` when needed.
197+
Use the shared `backlog` server name everywhere. The server finds the active project from your client's MCP roots, and re-resolves when you switch workspace or worktree. Until it finds one, it serves `backlog://init-required`. A single user-scope server covers every repo.
198198

199199
### Manual config
200200

@@ -212,7 +212,7 @@ Use the shared `backlog` server name everywhere – the MCP server auto-detects
212212
}
213213
```
214214

215-
If your IDE can't set the process working directory for MCP servers, set `BACKLOG_CWD` as shown above.
215+
Set `BACKLOG_CWD` to pin the server to one project and stop workspace following. Use it to always target the same backlog, or when your client can't report MCP roots. Roots-capable clients like Claude Code can drop the `env` block.
216216
If your IDE supports custom args but not env vars, you can also use `["mcp", "start", "--cwd", "/absolute/path/to/your/project"]`.
217217

218218
> [!IMPORTANT]
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
---
2+
id: BACK-558
3+
title: Resolve MCP project root from client workspace roots
4+
status: In Progress
5+
assignee:
6+
- "@ycaptain"
7+
created_date: "2026-06-14 06:58"
8+
labels:
9+
- mcp
10+
- bug
11+
dependencies: []
12+
priority: high
13+
---
14+
15+
## Description
16+
17+
<!-- SECTION:DESCRIPTION:BEGIN -->
18+
19+
The MCP server resolves its project root once at startup and only consults MCP roots in fallback mode, so a shared/user-scope server (or one launched in the main checkout while working in a git worktree) writes tasks to the wrong backlog directory. See #558. Extend the request-scoped MCP roots discovery introduced in #608 (BACK-434) to the normal (initialized) startup path so the server follows the client's workspace roots, while preserving explicit pins (--cwd/BACKLOG_CWD) and the behavior of clients without the roots capability.
20+
21+
<!-- SECTION:DESCRIPTION:END -->
22+
23+
## Acceptance Criteria
24+
25+
<!-- AC:BEGIN -->
26+
27+
- [x] #1 An unpinned shared/user-scope server resolves the project from the client's workspace roots instead of a frozen startup directory, and re-resolves on roots/list_changed.
28+
- [x] #2 An explicit --cwd/BACKLOG_CWD pin keeps a fixed root and never queries client roots.
29+
- [x] #3 When the client workspace has no backlog project (empty/unusable roots, or a folder without a backlog), an unpinned launch-directory project is kept rather than dropped to init-required; pin with --cwd/BACKLOG_CWD to target a fixed global backlog.
30+
- [x] #4 Clients without the roots capability keep the previous process.cwd() behavior, and the constraints from #570 are preserved (each root checked directly, multi-root selects the first advertised root that has a backlog config, single-flight).
31+
- [x] #5 Tests cover the git-worktree case from #558, the shared-server case, pin precedence, the nested monorepo case, and the no-roots fallback; the full suite passes.
32+
<!-- AC:END -->
33+
34+
## Implementation Plan
35+
36+
<!-- SECTION:PLAN:BEGIN -->
37+
38+
1. Thread a pinned flag from src/commands/mcp.ts into createMcpServer, derived from whether the directory came from --cwd/BACKLOG_CWD or process.cwd().
39+
2. Enable the existing request-scoped roots discovery on the normal (initialized) startup path too — gated by pinned — reusing upgradeToProject/downgradeToFallback/resolveFromRoots from #608 rather than rewriting the resolver.
40+
3. Track startupHasProject so a normal baseline keeps its project when the client workspace has no backlog (only a fallback/init-required baseline downgrades); make upgradeToProject's no-op short-circuit cover the normal baseline.
41+
4. Keep the #570 constraints (each root checked directly, multi-root first-with-config, single-flight).
42+
5. Cover cases in src/test/mcp-workspace-root.test.ts; update mcp-roots-discovery.test.ts for the confirm-once behavior; update the README MCP section.
43+
<!-- SECTION:PLAN:END -->
44+
45+
## Implementation Notes
46+
47+
<!-- SECTION:NOTES:BEGIN -->
48+
49+
Minimal patch on top of #608's request-scoped roots discovery — upgradeToProject/downgradeToFallback/resolveFromRoots are kept intact rather than rewriting the resolver.
50+
51+
- src/mcp/server.ts: the normal (initialized) startup path now also calls enableRootsDiscovery unless pinned; a startupHasProject flag makes a normal baseline keep its project when the client workspace has no backlog (only a fallback baseline reverts to init-required); upgradeToProject's no-op short-circuit covers the normal baseline so a client root equal to the launch dir does not re-register.
52+
- src/commands/mcp.ts threads pinned from --cwd/BACKLOG_CWD vs process.cwd().
53+
- Scope: this PR intentionally does NOT drop an unrelated launch project (e.g. a global ~/.backlog) or add monorepo-nesting rules; those were left out to keep it a minimal bugfix within the existing architecture. Pin with --cwd/BACKLOG_CWD to fix a global backlog.
54+
- Behavior change (reviewer note): the normal/initialized path now issues one roots/list request to follow the client workspace (#608 issued none in normal mode); a pin opts out entirely.
55+
56+
Full suite passes; bun run check . and bunx tsc --noEmit clean.
57+
58+
<!-- SECTION:NOTES:END -->

src/commands/mcp.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,10 @@ function registerStartCommand(mcpCmd: Command): void {
3838
try {
3939
const runtimeCwd = await resolveRuntimeCwd({ cwd: options.cwd });
4040
const projectRoot = (await findBacklogRoot(runtimeCwd.cwd)) ?? runtimeCwd.cwd;
41-
const server = await createMcpServer(projectRoot, { debug: options.debug });
41+
// An explicit --cwd/BACKLOG_CWD pins the root; an inferred process.cwd()
42+
// lets the server follow the client's workspace roots instead.
43+
const pinned = runtimeCwd.source !== "process";
44+
const server = await createMcpServer(projectRoot, { debug: options.debug, pinned });
4245

4346
await server.connect();
4447
await server.start();

src/mcp/server.ts

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,8 @@ const INSTRUCTIONS =
5757

5858
type ServerInitOptions = {
5959
debug?: boolean;
60+
/** When true (from --cwd/BACKLOG_CWD), the root is fixed and client roots are never consulted. */
61+
pinned?: boolean;
6062
};
6163

6264
type ServerRequestExtra = RequestHandlerExtra<ServerRequest, ServerNotification>;
@@ -81,6 +83,9 @@ export class McpServer extends Core {
8183
/** True when the server has been upgraded from fallback to a real project. */
8284
private upgraded = false;
8385

86+
/** True when the launch directory was itself an initialized project (vs fallback). */
87+
private startupHasProject = false;
88+
8489
private readonly tools = new Map<string, McpToolHandler>();
8590
private readonly resources = new Map<string, McpResourceHandler>();
8691
private readonly prompts = new Map<string, McpPromptHandler>();
@@ -109,16 +114,20 @@ export class McpServer extends Core {
109114
}
110115

111116
/**
112-
* Enable roots-based project discovery for fallback mode.
117+
* Enable roots-based project discovery so the server follows the client
118+
* workspace. Used from both fallback mode and a normal (initialized) startup;
119+
* pass `startupHasProject` for the latter so an unusable client workspace
120+
* returns to the launch-directory project instead of init-required.
113121
*
114122
* The first request-scoped handler invocation can query MCP roots to look
115123
* for a valid backlog project. If found, the server reinitializes the Core,
116124
* registers the full toolset, and notifies the client. Subsequent requests
117125
* reuse the cached resolution until the client reports roots changes.
118126
*/
119-
enableRootsDiscovery(options?: { debug?: boolean }): void {
127+
enableRootsDiscovery(options?: { debug?: boolean; startupHasProject?: boolean }): void {
120128
this.rootsDiscoveryEnabled = true;
121-
this.rootsDiscoveryOptions = options ?? {};
129+
this.rootsDiscoveryOptions = { debug: options?.debug };
130+
this.startupHasProject = options?.startupHasProject ?? false;
122131
this.rootsResolutionDirty = true;
123132
}
124133

@@ -177,7 +186,11 @@ export class McpServer extends Core {
177186
}
178187
}
179188

180-
if (this.upgraded) {
189+
// No usable client root. A launch-directory (normal) baseline returns to its
190+
// own project rather than a now-stale one; a fallback baseline reverts to init-required.
191+
if (this.startupHasProject) {
192+
await this.upgradeToProject(this.initialProjectRoot, options);
193+
} else if (this.upgraded) {
181194
await this.downgradeToFallback(options);
182195
}
183196

@@ -220,7 +233,7 @@ export class McpServer extends Core {
220233
* toolset, replacing fallback-mode registrations.
221234
*/
222235
private async upgradeToProject(projectRoot: string, options?: { debug?: boolean }): Promise<boolean> {
223-
if (this.upgraded && this.filesystem.rootDir === projectRoot) {
236+
if (this.filesystem.rootDir === projectRoot && (this.upgraded || this.startupHasProject)) {
224237
this.log(`MCP roots still resolve to current project: ${projectRoot}`, options);
225238
return true;
226239
}
@@ -505,7 +518,12 @@ export async function createMcpServer(projectRoot: string, options: ServerInitOp
505518
// and enable roots discovery so the server can find the project via MCP roots
506519
if (!config) {
507520
registerInitRequiredResource(server, projectRoot);
508-
server.enableRootsDiscovery({ debug: options.debug });
521+
if (!options.pinned) {
522+
server.enableRootsDiscovery({
523+
debug: options.debug,
524+
startupHasProject: false,
525+
});
526+
}
509527

510528
if (options.debug) {
511529
console.error("MCP server initialised in fallback mode (roots discovery enabled).");
@@ -522,6 +540,15 @@ export async function createMcpServer(projectRoot: string, options: ServerInitOp
522540
registerDefinitionOfDoneTools(server);
523541
registerDocumentTools(server, config);
524542

543+
// Follow the client workspace roots so a server launched in the main checkout
544+
// (or a shared/user-scope server) targets the active project, not a frozen one.
545+
if (!options.pinned) {
546+
server.enableRootsDiscovery({
547+
debug: options.debug,
548+
startupHasProject: true,
549+
});
550+
}
551+
525552
if (options.debug) {
526553
console.error("MCP server initialised (stdio transport only).");
527554
}

src/test/mcp-roots-discovery.test.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -246,7 +246,7 @@ describe("MCP roots discovery", () => {
246246
}
247247
});
248248

249-
it("normal mode does not issue roots/list requests", async () => {
249+
it("confirms an initialized launch directory against client roots exactly once", async () => {
250250
const { projectRoot } = await setupDirs();
251251

252252
const server = await createMcpServer(projectRoot);
@@ -256,6 +256,30 @@ describe("MCP roots discovery", () => {
256256
try {
257257
const tools = await client.listTools();
258258
expect(tools.tools.map((tool) => tool.name)).toContain("task_create");
259+
expect(server.filesystem.rootDir).toBe(projectRoot);
260+
expect(getRootsRequestCount()).toBe(1);
261+
262+
// Matching roots keep the same project and the resolution is cached.
263+
await client.listResources();
264+
expect(server.filesystem.rootDir).toBe(projectRoot);
265+
expect(getRootsRequestCount()).toBe(1);
266+
} finally {
267+
await client.close();
268+
await server.stop();
269+
}
270+
});
271+
272+
it("pinned server never issues roots/list requests", async () => {
273+
const { projectRoot, secondProjectRoot } = await setupDirs();
274+
275+
const server = await createMcpServer(projectRoot, { pinned: true });
276+
const rootsRef = { current: [pathToFileURL(secondProjectRoot).toString()] };
277+
const { client, getRootsRequestCount } = await connectRootsClient(server, rootsRef);
278+
279+
try {
280+
const tools = await client.listTools();
281+
expect(tools.tools.map((tool) => tool.name)).toContain("task_create");
282+
expect(server.filesystem.rootDir).toBe(projectRoot);
259283
expect(getRootsRequestCount()).toBe(0);
260284
} finally {
261285
await client.close();

0 commit comments

Comments
 (0)