Skip to content

Commit a2f2cd7

Browse files
committed
MCP workflow hardening (extracted from OpenClaw PR)
Adds and hardens the DeepWork MCP workflow runtime: - New tools: get_active_workflow, validate_step_outputs - Platform-aware workflow invocation / review-guidance text - StateManager/StatusWriter.set_project_root rebind support - RootResolver normalizes OpenClaw plugin-bundle roots to the enclosing workspace - FORMATTERS registry with format_for_openclaw - short_instruction_filename alias files for OpenClaw review spawns - Instruction/test coverage for the above Extracted from the feat/openclaw-support branch (PR #387) so the MCP runtime changes can be reviewed separately from the OpenClaw bundle content. Note: tests/unit/review/test_formatter.py::TestFormatForOpenClaw::test_output_mentions_sessions_spawn and ::test_agent_name_becomes_agent_type were failing on the openclaw branch before extraction (pre-existing NameError: '_task_name' helper missing in format_for_openclaw). Left as-is for reviewer visibility.
1 parent 21f23cf commit a2f2cd7

19 files changed

Lines changed: 882 additions & 88 deletions

doc/mcp_interface.md

Lines changed: 71 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ This document describes the Model Context Protocol (MCP) tools exposed by the De
1010

1111
## Tools
1212

13-
DeepWork exposes eleven MCP tools:
13+
DeepWork exposes thirteen MCP tools:
1414

1515
### 1. `get_workflows`
1616

@@ -54,7 +54,64 @@ interface WorkflowInfo {
5454

5555
---
5656

57-
### 2. `start_workflow`
57+
### 2. `get_active_workflow`
58+
59+
Return the currently active workflow for a session, if one exists. This is useful after compaction, reset, or any host-specific session restore flow.
60+
61+
#### Parameters
62+
63+
| Parameter | Type | Required | Description |
64+
|-----------|------|----------|-------------|
65+
| `session_id` | `string` | Yes | The persistent DeepWork session ID for the current host session. In Claude Code this is `CLAUDE_CODE_SESSION_ID`. |
66+
| `agent_id` | `string \| null` | No | Optional host-specific agent identifier for agent-scoped workflow state. In Claude Code this is `CLAUDE_CODE_AGENT_ID`. |
67+
68+
#### Returns
69+
70+
```typescript
71+
{
72+
has_active_workflow: boolean;
73+
stack: StackEntry[];
74+
active_workflow?: {
75+
job_name: string;
76+
workflow_name: string;
77+
goal: string;
78+
started_at: string;
79+
step_number: number;
80+
total_steps: number;
81+
completed_steps: string[];
82+
current_step: ActiveStepInfo;
83+
} | null;
84+
}
85+
```
86+
87+
---
88+
89+
### 3. `validate_step_outputs`
90+
91+
Validate a planned `finished_step` payload against the active step without advancing the workflow or running quality reviews. Use this as a dry run when you want to catch wrong output names, missing required outputs, bad types, or missing files before calling `finished_step`.
92+
93+
#### Parameters
94+
95+
| Parameter | Type | Required | Description |
96+
|-----------|------|----------|-------------|
97+
| `outputs` | `Record<string, string \| string[]>` | Yes | Map of planned step output names to values. Validation uses the active step's declared output contract without advancing the workflow. |
98+
| `session_id` | `string` | Yes | The persistent DeepWork session ID for the current host session. In Claude Code this is `CLAUDE_CODE_SESSION_ID`. |
99+
| `agent_id` | `string \| null` | No | Optional host-specific agent identifier for agent-scoped workflow state. In Claude Code this is `CLAUDE_CODE_AGENT_ID`. |
100+
101+
#### Returns
102+
103+
```typescript
104+
{
105+
valid: boolean;
106+
errors: string[];
107+
current_step: ActiveStepInfo;
108+
stack: StackEntry[];
109+
}
110+
```
111+
112+
---
113+
114+
### 4. `start_workflow`
58115

59116
Start a new workflow session. Initializes state tracking and returns the first step's instructions. Supports nested workflows — starting a workflow while one is active pushes onto a stack.
60117

@@ -82,7 +139,7 @@ Start a new workflow session. Initializes state tracking and returns the first s
82139

83140
---
84141

85-
### 3. `finished_step`
142+
### 5. `finished_step`
86143

87144
Report that you've finished a workflow step. Validates outputs and runs quality reviews (from step definitions and .deepreview rules), then returns the next action.
88145

@@ -121,7 +178,7 @@ Report that you've finished a workflow step. Validates outputs and runs quality
121178

122179
---
123180

124-
### 4. `abort_workflow`
181+
### 6. `abort_workflow`
125182

126183
Abort the current workflow and return to the parent workflow (if nested). Use this when a workflow cannot be completed.
127184

@@ -149,7 +206,7 @@ Abort the current workflow and return to the parent workflow (if nested). Use th
149206

150207
---
151208

152-
### 5. `go_to_step`
209+
### 7. `go_to_step`
153210

154211
Navigate back to a prior step in the current workflow. Clears all progress from the target step onward, forcing re-execution of subsequent steps to ensure consistency. Use this when earlier outputs need revision or quality issues are discovered in later steps.
155212

@@ -181,7 +238,7 @@ Navigate back to a prior step in the current workflow. Clears all progress from
181238

182239
---
183240

184-
### 6. `get_review_instructions`
241+
### 8. `get_review_instructions`
185242

186243
Run a review of changed files based on `.deepreview` configuration files and DeepSchema-generated synthetic review rules. Returns a list of review tasks to invoke in parallel. Each task has `description`, `subagent_type`, and `prompt` fields for the Agent tool.
187244

@@ -201,7 +258,7 @@ A plain string with one of:
201258

202259
---
203260

204-
### 7. `get_configured_reviews`
261+
### 9. `get_configured_reviews`
205262

206263
List all configured review rules from `.deepreview` files and DeepSchema-generated synthetic rules. Returns each rule's name, description, and defining file location. Optionally filters to rules matching specific files.
207264

@@ -225,7 +282,7 @@ Array<{
225282

226283
---
227284

228-
### 8. `mark_review_as_passed`
285+
### 10. `mark_review_as_passed`
229286

230287
Mark a review as passed so it won't be re-run while reviewed files remain unchanged. Call this when a review has no findings, when all findings have been fixed, or when remaining findings have been explicitly dismissed by the user. The `review_id` is provided in the instruction file's "After Review" section.
231288

@@ -245,7 +302,7 @@ A plain string with either:
245302

246303
---
247304

248-
### 9. `get_named_schemas`
305+
### 11. `get_named_schemas`
249306

250307
List all named DeepSchemas discovered across all schema sources (project-local, standard, and env var). Returns each schema's name, summary, and matcher patterns.
251308

@@ -263,7 +320,7 @@ Array<{
263320
}>
264321
```
265322

266-
### 10. `register_session_job`
323+
### 12. `register_session_job`
267324

268325
Register a transient job definition scoped to the current session. The job is validated against the job schema and stored so that `start_workflow` can discover it. Can be called multiple times to overwrite.
269326

@@ -288,7 +345,7 @@ Register a transient job definition scoped to the current session. The job is va
288345

289346
On validation failure, returns `{ error: string }` with details about what failed.
290347

291-
### 11. `get_session_job`
348+
### 13. `get_session_job`
292349

293350
Retrieve the YAML content of a session-scoped job definition previously registered with `register_session_job`.
294351

@@ -375,7 +432,9 @@ The `finished_step` tool returns one of three statuses:
375432
|
376433
3. Execute step instructions, create outputs
377434
|
378-
4. finished_step(outputs, session_id)
435+
4. validate_step_outputs(outputs, session_id) // optional dry run
436+
|
437+
5. finished_step(outputs, session_id)
379438
|
380439
+-- status = "needs_work" -> Fix issues, goto 4
381440
+-- status = "next_step" -> Execute new instructions, goto 4

src/deepwork/jobs/mcp/quality_gate.py

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
)
2323
from deepwork.review.config import ReviewRule, ReviewTask
2424
from deepwork.review.discovery import load_all_rules
25-
from deepwork.review.formatter import format_for_claude
25+
from deepwork.review.formatter import FORMATTERS, format_for_claude
2626
from deepwork.review.instructions import (
2727
write_instruction_files,
2828
)
@@ -461,16 +461,35 @@ def run_quality_gate(
461461
return None
462462

463463
# 9. Format as review instructions
464-
review_output = format_for_claude(task_files, project_root)
464+
formatter = FORMATTERS.get(platform, format_for_claude)
465+
review_output = formatter(task_files, project_root)
465466

466467
# 10. Build complete response with guidance
467-
guidance = _build_review_guidance(review_output)
468+
guidance = _build_review_guidance(review_output, platform)
468469

469470
return guidance
470471

471472

472-
def _build_review_guidance(review_output: str) -> str:
473+
def _build_review_guidance(review_output: str, platform: str = "claude") -> str:
473474
"""Build the complete review guidance including /review skill instructions."""
475+
if platform == "openclaw":
476+
return f"""Quality reviews are required before this step can advance.
477+
478+
{review_output}
479+
480+
## How to Run Reviews
481+
482+
For each review task listed above, launch it as a parallel OpenClaw sub-agent with `sessions_spawn`.
483+
484+
- Spawn every listed review before waiting for any completion event.
485+
- Use each instruction path exactly as written, relative to the workspace root. Do not rewrite it as an absolute host path.
486+
- Do not set `timeoutSeconds` on these review spawns; let the runtime default apply. If the tool requires a timeout value, use `0`.
487+
- After all spawns are accepted, use `sessions_yield` to wait for completion events before continuing.
488+
489+
## After Reviews
490+
491+
For any failing reviews, if you believe the issue is invalid, then you can call `mark_review_as_passed` on it. Otherwise, you should act on any feedback from the review to fix the issues. Once done, call `finished_step` again to see if you will pass now."""
492+
474493
return f"""Quality reviews are required before this step can advance.
475494
476495
{review_output}

src/deepwork/jobs/mcp/roots.py

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,16 @@
11
"""MCP root resolution via listRoots client capability.
22
33
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()``
4+
filesystem roots. When ``--path`` is explicitly passed on the CLI the
5+
resolver always returns that path. Otherwise it calls ``ctx.list_roots()``
66
on every tool invocation so it tracks workspace changes (e.g. git worktree
77
switches) without caching stale values.
8+
9+
For OpenClaw bundle installs, the MCP server can be launched from the plugin
10+
bundle directory itself (for example ``plugins/openclaw``) when the host does
11+
not expose a usable ``listRoots`` capability. In that case we normalize the
12+
bundle directory back to the enclosing workspace root when we can detect
13+
OpenClaw workspace markers.
814
"""
915

1016
from __future__ import annotations
@@ -19,6 +25,9 @@
1925

2026
logger = logging.getLogger("deepwork.jobs.mcp")
2127

28+
_OPENCLAW_PLUGIN_MARKER = Path(".codex-plugin") / "plugin.json"
29+
_OPENCLAW_WORKSPACE_MARKER = Path(".openclaw") / "workspace-state.json"
30+
2231

2332
async def resolve_project_root(ctx: Context, fallback: Path) -> Path:
2433
"""Ask the MCP client for its filesystem root.
@@ -78,4 +87,24 @@ async def get_root(self, ctx: Context) -> Path:
7887
"""
7988
if self._explicit:
8089
return self._fallback
81-
return await resolve_project_root(ctx, self._fallback)
90+
candidate = await resolve_project_root(ctx, self._fallback)
91+
return _normalize_openclaw_bundle_root(candidate)
92+
93+
94+
def _normalize_openclaw_bundle_root(candidate: Path) -> Path:
95+
"""Map an OpenClaw plugin bundle path back to the workspace root."""
96+
97+
resolved = candidate.resolve()
98+
if not (resolved / _OPENCLAW_PLUGIN_MARKER).exists():
99+
return resolved
100+
101+
for ancestor in (resolved, *resolved.parents):
102+
if (ancestor / _OPENCLAW_WORKSPACE_MARKER).exists():
103+
logger.debug(
104+
"Normalized OpenClaw plugin bundle root %s to workspace root %s",
105+
resolved,
106+
ancestor,
107+
)
108+
return ancestor
109+
110+
return resolved

src/deepwork/jobs/mcp/schemas.py

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,31 @@ class FinishedStepInput(BaseModel):
144144
)
145145

146146

147+
class ValidateStepOutputsInput(BaseModel):
148+
"""Input for validate_step_outputs tool."""
149+
150+
outputs: dict[str, ArgumentValue] = Field(
151+
description=(
152+
"Map of planned step output names to values. "
153+
"Validation uses the active step's declared output contract without "
154+
"advancing the workflow or running quality reviews."
155+
)
156+
)
157+
session_id: str = Field(
158+
description=(
159+
"The persistent DeepWork session ID for the current host session. "
160+
"In Claude Code this is CLAUDE_CODE_SESSION_ID."
161+
),
162+
)
163+
agent_id: str | None = Field(
164+
default=None,
165+
description=(
166+
"Optional host-specific agent identifier for agent-scoped workflow state. "
167+
"In Claude Code this is CLAUDE_CODE_AGENT_ID."
168+
),
169+
)
170+
171+
147172
class AbortWorkflowInput(BaseModel):
148173
"""Input for abort_workflow tool."""
149174

@@ -182,6 +207,24 @@ class GoToStepInput(BaseModel):
182207
)
183208

184209

210+
class GetActiveWorkflowInput(BaseModel):
211+
"""Input for get_active_workflow tool."""
212+
213+
session_id: str = Field(
214+
description=(
215+
"The persistent DeepWork session ID for the current host session. "
216+
"In Claude Code this is CLAUDE_CODE_SESSION_ID."
217+
),
218+
)
219+
agent_id: str | None = Field(
220+
default=None,
221+
description=(
222+
"Optional host-specific agent identifier for agent-scoped workflow state. "
223+
"In Claude Code this is CLAUDE_CODE_AGENT_ID."
224+
),
225+
)
226+
227+
185228
# =============================================================================
186229
# Tool Output Models
187230
# NOTE: Changes to these models affect MCP tool return types.
@@ -320,6 +363,23 @@ class FinishedStepResponse(BaseModel):
320363
)
321364

322365

366+
class ValidateStepOutputsResponse(BaseModel):
367+
"""Response from validate_step_outputs tool."""
368+
369+
valid: bool = Field(description="Whether the submitted outputs satisfy the active step contract")
370+
errors: list[str] = Field(
371+
default_factory=list,
372+
description="Validation errors that must be fixed before calling finished_step",
373+
)
374+
current_step: ActiveStepInfo = Field(
375+
description="The current step, including the declared expected outputs",
376+
)
377+
stack: list[StackEntry] = Field(
378+
default_factory=list,
379+
description="Current workflow stack after validation",
380+
)
381+
382+
323383
class AbortWorkflowResponse(BaseModel):
324384
"""Response from abort_workflow tool."""
325385

@@ -349,6 +409,40 @@ class GoToStepResponse(BaseModel):
349409
)
350410

351411

412+
class ActiveWorkflowState(BaseModel):
413+
"""Current active workflow session details."""
414+
415+
job_name: str = Field(description="Name of the active job")
416+
workflow_name: str = Field(description="Name of the active workflow")
417+
goal: str = Field(description="Goal originally supplied when the workflow started")
418+
started_at: str = Field(description="ISO timestamp when the workflow started")
419+
step_number: int = Field(description="1-based index of the current step")
420+
total_steps: int = Field(description="Total number of steps in the workflow")
421+
completed_steps: list[str] = Field(
422+
default_factory=list,
423+
description="Step IDs already completed in this workflow session",
424+
)
425+
current_step: ActiveStepInfo = Field(
426+
description="The active step and its current resolved instructions",
427+
)
428+
429+
430+
class GetActiveWorkflowResponse(BaseModel):
431+
"""Response from get_active_workflow tool."""
432+
433+
has_active_workflow: bool = Field(
434+
description="Whether the given session currently has an active workflow"
435+
)
436+
stack: list[StackEntry] = Field(
437+
default_factory=list,
438+
description="Current workflow stack visible to this session/agent",
439+
)
440+
active_workflow: ActiveWorkflowState | None = Field(
441+
default=None,
442+
description="Details of the active workflow when one exists",
443+
)
444+
445+
352446
# =============================================================================
353447
# Session Job Models
354448
# NOTE: These models support register_session_job / get_session_job tools.

0 commit comments

Comments
 (0)