Skip to content

Commit c078fb3

Browse files
kuworkMrLesk
andcommitted
BACK-522 - Resolve MCP project root from client workspace roots (MrLesk#687)
## Summary The MCP server chose its project root once at startup and only looked at the client's MCP roots when it had no project of its own (fallback mode). A server shared across projects, or one started in the main checkout while the active workspace is a git worktree, therefore kept writing tasks to whatever directory it launched in (MrLesk#558). This makes an already-initialized server follow the client workspace as well, reusing the request-scoped roots discovery that MrLesk#608 added for fallback mode. On its first request the server asks the client for its roots and switches to the matching project, then re-checks on every `roots/list_changed`. If the workspace has no backlog of its own, the server stays on its launch directory instead of dropping to "init required". Passing `--cwd` or `BACKLOG_CWD` pins the root and turns roots discovery off, which is what a deliberately shared server wants. ## Behavior change An initialized, unpinned server now sends one `roots/list` request on its first handled request, where before it sent none. If the client's root matches the launch directory, nothing is re-registered. Clients that do not advertise the roots capability keep the old `process.cwd()` behavior. A pinned server sends no roots requests at all. ## Implementation notes * `src/commands/mcp.ts` marks the root as pinned when it comes from `--cwd` or `BACKLOG_CWD`, and unpinned when it is only `process.cwd()`. * `src/mcp/server.ts` adds a `startupHasProject` flag so an initialized server returns to its own project when the client offers nothing usable, while a fallback server still reverts to init-required. * `upgradeToProject`, `downgradeToFallback` and `resolveFromRoots` are unchanged, as are the root checks from MrLesk#570: each root is checked on its own, no walking up the tree, one resolution in flight at a time. * README now documents workspace following and the `--cwd` / `BACKLOG_CWD` pin. ## Related Tasks Closes MrLesk#558. Task `backlog/tasks/back-558 - Resolve-MCP-project-root-from-client-workspace-roots.md` (BACK-558), acceptance criteria and DoD checked. ## Validation * `bun test src/test/mcp-workspace-root.test.ts src/test/mcp-roots-discovery.test.ts` (15 pass): following the client workspace from an unrelated launch directory, a pin that sends zero roots requests, a monorepo package kept, the launch project kept when the workspace has no backlog, the return to the launch project after the workspace loses its backlog, and the worktree case from MrLesk#558. * `bunx tsc --noEmit` * `bun run check .` --------- Co-authored-by: Alex Gavrilescu <leskcorp@gmail.com>
1 parent cdd0f3e commit c078fb3

7 files changed

Lines changed: 394 additions & 12 deletions

File tree

README.en.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -267,7 +267,7 @@ You can run `backlog init` (even if you already initialized Backlog.md) and choo
267267

268268
</details>
269269

270-
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.
270+
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.
271271

272272
### Manual config
273273

@@ -285,7 +285,7 @@ Use the shared `backlog` server name everywhere – the MCP server auto-detects
285285
}
286286
```
287287

288-
If your IDE can't set the process working directory for MCP servers, set `BACKLOG_CWD` as shown above.
288+
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.
289289
If your IDE supports custom args but not env vars, you can also use `["mcp", "start", "--cwd", "/absolute/path/to/your/project"]`.
290290

291291
> [!IMPORTANT]

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -267,7 +267,7 @@ CLI 指令是默认的 AI 接入方式。如果您明确偏好 MCP 连接器,B
267267

268268
</details>
269269

270-
处处统一使用 `backlog` 作为服务器名称 —— MCP 服务器会自动检测当前目录是否已初始化,并在需要时回退到 `backlog://init-required`
270+
处处统一使用 `backlog` 作为服务器名称。服务器会从客户端的 MCP roots 中查找当前项目,并在切换工作区或 worktree 时重新解析。找到项目前,它会提供 `backlog://init-required`。一个用户级服务器即可覆盖所有仓库
271271

272272
### 手动配置
273273

@@ -285,7 +285,7 @@ CLI 指令是默认的 AI 接入方式。如果您明确偏好 MCP 连接器,B
285285
}
286286
```
287287

288-
如果您的 IDE 无法为 MCP 服务器设置进程工作目录,如上所示设置 `BACKLOG_CWD`
288+
设置 `BACKLOG_CWD` 可将服务器固定到单个项目并停止工作区跟随。当你始终想定位同一个 backlog,或客户端无法上报 MCP roots 时使用它
289289
如果您的 IDE 支持自定义参数但不支持环境变量,您也可以使用 `["mcp", "start", "--cwd", "/absolute/path/to/your/project"]`
290290

291291
> [!IMPORTANT]
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
---
2+
id: BACK-522
3+
title: Resolve MCP project root from client workspace roots
4+
status: Done
5+
assignee:
6+
- '@ycaptain'
7+
created_date: '2026-06-14 06:58'
8+
updated_date: '2026-06-15 17:04'
9+
labels:
10+
- mcp
11+
- bug
12+
dependencies: []
13+
priority: high
14+
---
15+
16+
## Description
17+
18+
<!-- SECTION:DESCRIPTION:BEGIN -->
19+
20+
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.
21+
22+
<!-- SECTION:DESCRIPTION:END -->
23+
24+
## Acceptance Criteria
25+
26+
<!-- AC:BEGIN -->
27+
28+
- [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.
29+
- [x] #2 An explicit --cwd/BACKLOG_CWD pin keeps a fixed root and never queries client roots.
30+
- [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.
31+
- [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).
32+
- [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.
33+
<!-- AC:END -->
34+
35+
## Implementation Plan
36+
37+
<!-- SECTION:PLAN:BEGIN -->
38+
39+
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().
40+
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.
41+
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.
42+
4. Keep the #570 constraints (each root checked directly, multi-root first-with-config, single-flight).
43+
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.
44+
<!-- SECTION:PLAN:END -->
45+
46+
## Implementation Notes
47+
48+
<!-- SECTION:NOTES:BEGIN -->
49+
50+
Minimal patch on top of #608's request-scoped roots discovery — upgradeToProject/downgradeToFallback/resolveFromRoots are kept intact rather than rewriting the resolver.
51+
52+
- 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.
53+
- src/commands/mcp.ts threads pinned from --cwd/BACKLOG_CWD vs process.cwd().
54+
- 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.
55+
- 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.
56+
57+
Full suite passes; bun run check . and bunx tsc --noEmit clean.
58+
59+
<!-- 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)