Skip to content

Commit ae34fa7

Browse files
nhortonclaude
andauthored
feat: support work trees by resolving project root via MCP listRoots (#319)
* feat: resolve project root via MCP listRoots for worktree compatibility When --path is not explicitly passed to `deepwork serve`, the server now calls `ctx.list_roots()` on every MCP tool invocation to resolve the project root dynamically. This lets the server track workspace changes mid-session (e.g. when the client switches to a git worktree). When --path is explicitly provided, it is always used unconditionally. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address review findings — traceability, numbering, CLI tests - Fix duplicate numbering in JOBS-REQ-001.1 (items 5-9 renumbered to 5-10) - Add JOBS-REQ-011 traceability comments to all test_roots.py tests - Add CLI integration tests for JOBS-REQ-011.4 (explicit_path passing) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * style: format test_roots.py with ruff Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 200bc82 commit ae34fa7

11 files changed

Lines changed: 348 additions & 30 deletions

File tree

plugins/claude/.mcp.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"mcpServers": {
33
"deepwork": {
44
"command": "uvx",
5-
"args": ["deepwork", "serve", "--path", ".", "--platform", "claude"]
5+
"args": ["deepwork", "serve", "--platform", "claude"]
66
}
77
}
88
}

specs/deepwork/DW-REQ-005-cli-commands.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ The DeepWork CLI provides four active commands: `serve` (starts the MCP server),
1616
### DW-REQ-005.2: serve Command
1717

1818
1. The `serve` command MUST be a Click command.
19-
2. The `serve` command MUST accept a `--path` option (default: `"."`, must exist, must be a directory).
19+
2. The `serve` command MUST accept a `--path` option (default: `None`, must exist if provided, must be a directory). When omitted, the server MUST resolve the project root dynamically via MCP `listRoots` on each tool call, falling back to the process working directory at startup and when `listRoots` is unavailable. When `--path` is explicitly provided, the server MUST use the given path for all operations and MUST NOT consult `listRoots` (see JOBS-REQ-011).
2020
3. The `serve` command MUST accept a `--no-quality-gate` flag (default: False). When set, quality gate evaluation MUST be disabled.
2121
4. The `serve` command MUST accept a `--transport` option with choices `"stdio"` or `"sse"` (default: `"stdio"`).
2222
5. The `serve` command MUST accept a `--port` option (integer, default: 8000) for SSE transport.

specs/deepwork/cli_plugins/PLUG-REQ-001-claude-code-plugin.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ The Claude Code plugin is the primary distribution mechanism for DeepWork on the
1717
1. The plugin MUST register the DeepWork MCP server via `plugins/claude/.mcp.json`.
1818
2. The MCP server command MUST be `uvx` with a first argument starting with `deepwork` (to allow version-pinned variants like `deepwork==x.y.z`) and `serve` as the second argument.
1919
3. The MCP server arguments MUST include `--platform claude` so the server knows it is running under Claude Code.
20-
4. The MCP server arguments MUST include `--path .` so job discovery starts from the project root.
20+
4. The MCP server arguments MUST NOT include `--path` so that the server resolves the project root dynamically via MCP `listRoots` (see JOBS-REQ-011).
2121

2222
### PLUG-REQ-001.3: Deepwork Skill
2323

specs/deepwork/jobs/JOBS-REQ-001-mcp-workflow-tools.md

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,19 +9,20 @@ The DeepWork MCP server exposes workflow tools to AI agents via the Model Contex
99
### JOBS-REQ-001.1: Server Creation and Configuration
1010

1111
1. The system MUST provide a `create_server()` function that returns a configured `FastMCP` instance.
12-
2. The server MUST accept a `project_root` parameter (Path or str) and resolve it to an absolute path.
12+
2. The server MUST accept a `project_root` parameter (Path or str) and resolve it to an absolute path. This value is used as the startup/fallback root.
1313
3. The server MUST accept an optional `platform` parameter (str or None, default: `None`). When `None`, defaults to `"claude"`.
1414
4. The server MUST accept `**_kwargs` for backwards compatibility with removed parameters (`enable_quality_gate`, `quality_gate_timeout`, `quality_gate_max_attempts`, `external_runner`). These MUST be ignored.
15-
5. The server MUST be named `"deepwork"`.
16-
6. The server MUST include instructions text describing the workflow lifecycle.
17-
7. Every tool call MUST be logged with the tool name and current stack state.
18-
8. On startup, the server MUST copy `job.schema.json` from its package-bundled location to `.deepwork/job.schema.json` under the project root. If the copy fails, the server MUST log a warning and continue.
19-
9. On startup, the server MUST write an initial job manifest via `StatusWriter`.
15+
5. The server MUST accept an `explicit_path` keyword argument (bool, default: `True`). When `False`, tool handlers MUST resolve the project root dynamically via `RootResolver.get_root()` on each invocation (see JOBS-REQ-011).
16+
6. The server MUST be named `"deepwork"`.
17+
7. The server MUST include instructions text describing the workflow lifecycle.
18+
8. Every tool call MUST be logged with the tool name and current stack state.
19+
9. On startup, the server MUST copy `job.schema.json` from its package-bundled location to `.deepwork/job.schema.json` under the project root. If the copy fails, the server MUST log a warning and continue.
20+
10. On startup, the server MUST write an initial job manifest via `StatusWriter`.
2021

2122
### JOBS-REQ-001.2: get_workflows Tool
2223

23-
1. The `get_workflows` tool MUST be registered as a synchronous MCP tool.
24-
2. The tool MUST accept no parameters.
24+
1. The `get_workflows` tool MUST be registered as an asynchronous MCP tool.
25+
2. The tool MUST accept no user-visible parameters (FastMCP auto-injects `Context`).
2526
3. The tool MUST return a dictionary with a `jobs` key containing a list of job info objects.
2627
4. Each job info object MUST contain `name`, `summary`, and `workflows` fields.
2728
5. Each workflow info object MUST contain `name`, `summary`, and `how_to_invoke` fields.
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
# JOBS-REQ-011: MCP Root Resolution via listRoots
2+
3+
## Overview
4+
5+
The MCP server resolves the project root dynamically using the MCP `listRoots` client capability. This allows the server to track workspace changes mid-session (e.g. when the client switches to a git worktree). When `--path` is explicitly provided on the CLI, the server uses that path unconditionally and does not consult `listRoots`.
6+
7+
## Requirements
8+
9+
### JOBS-REQ-011.1: Root Resolver
10+
11+
1. The system MUST provide a `RootResolver` class in `deepwork.jobs.mcp.roots`.
12+
2. `RootResolver` MUST accept a `fallback_root` (Path) and an `explicit` (bool) keyword argument at construction.
13+
3. When `explicit` is `True`, `get_root()` MUST always return `fallback_root` without calling `list_roots()`.
14+
4. When `explicit` is `False`, `get_root()` MUST call `ctx.list_roots()` on every invocation.
15+
5. `RootResolver` MUST NOT cache roots across tool calls — the client may change roots mid-session (e.g. via worktree switches).
16+
6. `RootResolver` MUST provide a `startup_root` property that returns `fallback_root` for code that runs before a client connects.
17+
18+
### JOBS-REQ-011.2: Root Resolution Logic
19+
20+
1. The system MUST provide an async `resolve_project_root(ctx, fallback)` function.
21+
2. The function MUST call `ctx.list_roots()` and return the first root whose URI scheme is `file`.
22+
3. The function MUST convert `file://` URIs to `Path` objects using `urllib.parse.urlparse` and `urllib.parse.unquote` for correct handling of percent-encoded characters (e.g. spaces).
23+
4. The returned `Path` MUST be resolved to an absolute path.
24+
5. If `list_roots()` raises an exception, the function MUST return `fallback`.
25+
6. If `list_roots()` returns an empty list, the function MUST return `fallback`.
26+
7. If no root with a `file` URI scheme is found, the function MUST return `fallback`.
27+
28+
### JOBS-REQ-011.3: Tool Handler Integration
29+
30+
1. All MCP tool handlers (`get_workflows`, `start_workflow`, `finished_step`, `abort_workflow`, `go_to_step`, `get_named_schemas`, `get_review_instructions`, `get_configured_reviews`, `mark_review_as_passed`) MUST accept a `Context` parameter (auto-injected by FastMCP).
31+
2. All MCP tool handlers MUST call `root_resolver.get_root(ctx)` to obtain the project root before executing their logic.
32+
3. The resolved root MUST be used for job discovery, schema discovery, review rule discovery, file path validation, and all other operations that depend on the project root.
33+
4. Startup-time operations (schema copy, manifest writing, issue detection, StateManager/StatusWriter construction) MUST continue using the startup root, not `listRoots`.
34+
35+
### JOBS-REQ-011.4: CLI Integration
36+
37+
1. When `--path` is not provided to `deepwork serve`, the CLI MUST pass `explicit_path=False` to `create_server()`.
38+
2. When `--path` is explicitly provided, the CLI MUST pass `explicit_path=True` to `create_server()`.
39+
3. When `--path` is not provided, the CLI MUST use `Path.cwd()` as the fallback root.

src/deepwork/cli/serve.py

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,9 @@ class ServeError(Exception):
1515
@click.option(
1616
"--path",
1717
type=click.Path(exists=True, file_okay=False, path_type=Path),
18-
default=".",
19-
help="Path to project directory (default: current directory)",
18+
default=None,
19+
help="Explicit project directory. When omitted, the server resolves "
20+
"the root dynamically via MCP listRoots (falling back to cwd).",
2021
)
2122
@click.option(
2223
"--no-quality-gate",
@@ -51,7 +52,7 @@ class ServeError(Exception):
5152
help="Platform identifier (e.g., 'claude'). Used by the review tool to format output.",
5253
)
5354
def serve(
54-
path: Path,
55+
path: Path | None,
5556
no_quality_gate: bool,
5657
transport: str,
5758
port: int,
@@ -77,8 +78,11 @@ def serve(
7778
# SSE transport for remote access
7879
deepwork serve --transport sse --port 8000
7980
"""
81+
explicit_path = path is not None
82+
resolved_path = path if path is not None else Path.cwd()
83+
8084
try:
81-
_serve_mcp(path, transport, port, platform)
85+
_serve_mcp(resolved_path, transport, port, platform, explicit_path=explicit_path)
8286
except ServeError as e:
8387
click.echo(f"Error: {e}", err=True)
8488
raise click.Abort() from e
@@ -92,6 +96,8 @@ def _serve_mcp(
9296
transport: str,
9397
port: int,
9498
platform: str | None = None,
99+
*,
100+
explicit_path: bool = True,
95101
) -> None:
96102
"""Start the MCP server.
97103
@@ -100,6 +106,7 @@ def _serve_mcp(
100106
transport: Transport protocol (stdio or sse)
101107
port: Port for SSE transport
102108
platform: Platform identifier for the review tool (e.g., "claude").
109+
explicit_path: Whether --path was explicitly provided by the user.
103110
104111
Raises:
105112
ServeError: If server fails to start
@@ -121,6 +128,7 @@ def _serve_mcp(
121128
server = create_server(
122129
project_root=project_path,
123130
platform=platform,
131+
explicit_path=explicit_path,
124132
)
125133

126134
if transport == "stdio":

src/deepwork/jobs/mcp/roots.py

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
"""MCP root resolution via listRoots client capability.
2+
3+
Resolves the project root dynamically by asking the MCP client for its
4+
filesystem roots. When ``--path`` is explicitly passed on the CLI the
5+
resolver always returns that path. Otherwise it calls ``ctx.list_roots()``
6+
on every tool invocation so it tracks workspace changes (e.g. git worktree
7+
switches) without caching stale values.
8+
"""
9+
10+
from __future__ import annotations
11+
12+
import logging
13+
from pathlib import Path
14+
from typing import TYPE_CHECKING
15+
from urllib.parse import unquote, urlparse
16+
17+
if TYPE_CHECKING:
18+
from fastmcp import Context
19+
20+
logger = logging.getLogger("deepwork.jobs.mcp")
21+
22+
23+
async def resolve_project_root(ctx: Context, fallback: Path) -> Path:
24+
"""Ask the MCP client for its filesystem root.
25+
26+
Calls ``ctx.list_roots()`` and returns the first root whose URI uses the
27+
``file`` scheme. Falls back to *fallback* when the call fails, returns
28+
no roots, or none of the roots use the ``file`` scheme.
29+
"""
30+
try:
31+
roots = await ctx.list_roots()
32+
except Exception:
33+
logger.debug("list_roots unavailable, using fallback %s", fallback)
34+
return fallback
35+
36+
for root in roots:
37+
uri = str(root.uri)
38+
parsed = urlparse(uri)
39+
if parsed.scheme == "file":
40+
path = Path(unquote(parsed.path)).resolve()
41+
logger.debug("Resolved project root from listRoots: %s", path)
42+
return path
43+
44+
logger.debug("No file:// root found, using fallback %s", fallback)
45+
return fallback
46+
47+
48+
class RootResolver:
49+
"""Resolve the project root for each MCP tool call.
50+
51+
Parameters
52+
----------
53+
fallback_root:
54+
The directory to use when ``list_roots`` is unavailable (typically
55+
the process working directory or an explicit ``--path`` value).
56+
explicit:
57+
When ``True`` the *fallback_root* was explicitly provided via
58+
``--path`` and MUST be used unconditionally. ``list_roots`` is
59+
never consulted.
60+
"""
61+
62+
def __init__(self, fallback_root: Path, *, explicit: bool) -> None:
63+
self._fallback = fallback_root
64+
self._explicit = explicit
65+
66+
@property
67+
def startup_root(self) -> Path:
68+
"""Return the root for startup code that runs before a client connects."""
69+
return self._fallback
70+
71+
async def get_root(self, ctx: Context) -> Path:
72+
"""Return the project root for the current tool invocation.
73+
74+
When ``--path`` was explicitly set, returns *fallback_root* without
75+
consulting the client. Otherwise calls ``list_roots()`` every time
76+
so workspace changes (e.g. worktree switches) are picked up
77+
immediately.
78+
"""
79+
if self._explicit:
80+
return self._fallback
81+
return await resolve_project_root(ctx, self._fallback)

0 commit comments

Comments
 (0)