docs(mcp_client): document new Result return types + nil-sentinel pattern
This commit is contained in:
+133
-5
@@ -83,16 +83,39 @@ Returns `False` for any path the AI is not allowed to touch.
|
|||||||
|
|
||||||
The final gate. Resolves the path (handling symlinks, relative paths) and re-checks.
|
The final gate. Resolves the path (handling symlinks, relative paths) and re-checks.
|
||||||
|
|
||||||
|
> **As of 2026-06-11:** This section documents the **post-refactor**
|
||||||
|
> `Result[Path]` signature, applied by the
|
||||||
|
> `data_oriented_error_handling_20260606` track. The pre-refactor
|
||||||
|
> `(Path | None, str)` tuple and the 30+ `assert p is not None` chain
|
||||||
|
> in tool bodies (lines 304-794) are replaced. See the new
|
||||||
|
> [Data-Oriented Error Handling (Fleury Pattern)](#data-oriented-error-handling-fleury-pattern)
|
||||||
|
> section below for the full convention.
|
||||||
|
|
||||||
```python
|
```python
|
||||||
def _resolve_and_check(raw_path: str) -> tuple[Path | None, str]:
|
def _resolve_and_check(raw_path: str) -> Result[Path]:
|
||||||
"""Resolve raw_path and verify it passes the allowlist check."""
|
"""Resolve raw_path and verify it passes the allowlist check.
|
||||||
|
|
||||||
|
On success: result.data is the real pathlib.Path; result.errors is [].
|
||||||
|
On failure: result.data is NIL_PATH; result.errors has 1 ErrorInfo
|
||||||
|
with kind=ErrorKind.PERMISSION (or NOT_FOUND / INVALID_INPUT).
|
||||||
|
"""
|
||||||
p = Path(raw_path).resolve()
|
p = Path(raw_path).resolve()
|
||||||
if not _is_allowed(p):
|
if not _is_allowed(p):
|
||||||
return None, f"ERROR: path not in allowlist: {raw_path}"
|
return Result(
|
||||||
return p, ""
|
data=NIL_PATH,
|
||||||
|
errors=[ErrorInfo(
|
||||||
|
kind=ErrorKind.PERMISSION,
|
||||||
|
message=f"path not in allowlist: {raw_path}",
|
||||||
|
source="mcp._resolve_and_check",
|
||||||
|
)],
|
||||||
|
)
|
||||||
|
return Result(data=p)
|
||||||
```
|
```
|
||||||
|
|
||||||
Every tool function calls this first. If it returns an error, the tool returns the error string to the AI.
|
Every tool function calls this first. If `result.errors` is non-empty, the
|
||||||
|
tool returns its own `Result[data="", errors=resolved.errors]` to propagate
|
||||||
|
the gate's error to the AI. The 3-layer security model is preserved
|
||||||
|
unchanged — only the return-type contract evolves.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -404,6 +427,111 @@ def test_my_code(monkeypatch):
|
|||||||
- **Tree-sitter parsing**: ~10-50ms per file for typical Python files. Cached in `_ast_cache` (mtime-based).
|
- **Tree-sitter parsing**: ~10-50ms per file for typical Python files. Cached in `_ast_cache` (mtime-based).
|
||||||
- **Network tools** (`web_search`, `fetch_url`): 100ms-2s depending on the network.
|
- **Network tools** (`web_search`, `fetch_url`): 100ms-2s depending on the network.
|
||||||
|
|
||||||
|
## Data-Oriented Error Handling (Fleury Pattern)
|
||||||
|
|
||||||
|
The MCP tool layer follows the "errors are just cases" framework
|
||||||
|
(Ryan Fleury). The canonical reference is
|
||||||
|
[`conductor/code_styleguides/error_handling.md`](../conductor/code_styleguides/error_handling.md).
|
||||||
|
|
||||||
|
### Result-Based Returns
|
||||||
|
|
||||||
|
The 9 tool functions that previously returned `(Path | None, str)` tuples
|
||||||
|
or raised exceptions now return `Result[str]` for content and
|
||||||
|
`Result[Path]` for the resolution gate:
|
||||||
|
|
||||||
|
| Function | Old signature | New signature |
|
||||||
|
|---|---|---|
|
||||||
|
| `_resolve_and_check(raw_path)` | `tuple[Path \| None, str]` | `Result[Path]` (data is real `Path` or `NIL_PATH`) |
|
||||||
|
| `read_file(path)` | `str` (error prefix) | `Result[str]` (data is `""` on failure) |
|
||||||
|
| `list_directory(path)` | `str` (error prefix) | `Result[str]` (data is `""` on failure) |
|
||||||
|
| `search_files(...)` | `str` (error prefix) | `Result[str]` (data is `""` on failure) |
|
||||||
|
| `get_file_summary(path)` | `str` (error prefix) | `Result[str]` (data is `""` on failure) |
|
||||||
|
| `py_get_skeleton(path)` | `str` (error prefix) | `Result[str]` (data is `""` on failure) |
|
||||||
|
| `py_get_code_outline(path)` | `str` (error prefix) | `Result[str]` (data is `""` on failure) |
|
||||||
|
| `py_get_definition(path, name)` | `str` (error prefix) | `Result[str]` (data is `""` on failure) |
|
||||||
|
| `py_get_imports(path)` | `str` (error prefix) | `Result[str]` (data is `""` on failure) |
|
||||||
|
| (and 35 more — all 45 tools) | `str` (error prefix) | `Result[str]` (data is `""` on failure) |
|
||||||
|
|
||||||
|
### Nil-Sentinel Pattern
|
||||||
|
|
||||||
|
The `NIL_PATH` dataclass is the "empty path" — it has all default values
|
||||||
|
(`exists=False`, `read_text=""`, `errors=[]`) and is safe to read from:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class NilPath:
|
||||||
|
exists: bool = False
|
||||||
|
read_text: str = ""
|
||||||
|
errors: list[ErrorInfo] = field(default_factory=list)
|
||||||
|
|
||||||
|
NIL_PATH = NilPath() # module-level singleton
|
||||||
|
```
|
||||||
|
|
||||||
|
Callers that need a real `pathlib.Path` for filesystem operations check
|
||||||
|
`if isinstance(result.data, NilPath): handle()` — but most callers just
|
||||||
|
need the read text, and `NIL_PATH.read_text == ""` is fine for the AI
|
||||||
|
model's purposes. This eliminates the 30+ `assert p is not None` chain
|
||||||
|
in tool bodies (lines 304-794 pre-refactor) and the
|
||||||
|
`if err or p is None: return err` patterns at the top of every tool
|
||||||
|
function.
|
||||||
|
|
||||||
|
### Dispatch Internals
|
||||||
|
|
||||||
|
The `dispatch` and `async_dispatch` functions unwrap the `Result` before
|
||||||
|
returning to the AI model (so the model's view of MCP errors is unchanged
|
||||||
|
— it still sees error messages as plain strings):
|
||||||
|
|
||||||
|
```python
|
||||||
|
def dispatch(tool_name: str, tool_input: dict) -> str:
|
||||||
|
result = _DISPATCH_TABLE[tool_name](tool_input)
|
||||||
|
if not result.ok:
|
||||||
|
for err in result.errors:
|
||||||
|
_append_comms("WARN", "mcp_tool_error", [err.ui_message()])
|
||||||
|
return result.data or "".join(e.message for e in result.errors)
|
||||||
|
```
|
||||||
|
|
||||||
|
The `async_dispatch` path handles the case where `mcp_client` has no
|
||||||
|
comms log: it just returns `result.data` (the empty success value) and
|
||||||
|
the errors are silently dropped. The Result's `data` field is always
|
||||||
|
readable (zero-initialized) so callers don't need defensive `is None`
|
||||||
|
checks.
|
||||||
|
|
||||||
|
### Example
|
||||||
|
|
||||||
|
```python
|
||||||
|
from src import mcp_client
|
||||||
|
from src.result_types import ErrorKind
|
||||||
|
|
||||||
|
r = mcp_client.read_file("/path/to/file.py")
|
||||||
|
if r.errors:
|
||||||
|
for err in r.errors:
|
||||||
|
if err.kind == ErrorKind.PERMISSION:
|
||||||
|
log.warning("path not in allowlist: %s", err.message)
|
||||||
|
elif err.kind == ErrorKind.NOT_FOUND:
|
||||||
|
log.info("file not found: %s", err.message)
|
||||||
|
else:
|
||||||
|
log.error(err.ui_message())
|
||||||
|
# use r.data regardless (it's the zero-initialized "" on failure)
|
||||||
|
process(r.data)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Security Invariant
|
||||||
|
|
||||||
|
The 3-layer security model (Allowlist → Validate → Resolve) is **preserved
|
||||||
|
unchanged** by the refactor. The new `Result` return type only changes
|
||||||
|
the *signature* of the tool functions; the *behavior* (the 3 layers must
|
||||||
|
all pass) is identical. The `ErrorKind.PERMISSION` value is what the
|
||||||
|
model sees when the allowlist rejects a path — same error condition as
|
||||||
|
the pre-refactor `"ERROR: path not in allowlist: ..."` string, just
|
||||||
|
typed data instead of stringly-typed control flow.
|
||||||
|
|
||||||
|
### See Also (in-doc)
|
||||||
|
|
||||||
|
- [`conductor/code_styleguides/error_handling.md`](../conductor/code_styleguides/error_handling.md) — canonical styleguide (5 patterns, data model, decision tree, anti-patterns)
|
||||||
|
- [`conductor/tracks/data_oriented_error_handling_20260606/spec.md`](../conductor/tracks/data_oriented_error_handling_20260606/spec.md) — the spec that introduced this pattern
|
||||||
|
- [`docs/guide_ai_client.md`](guide_ai_client.md#data-oriented-error-handling-fleury-pattern) — same pattern in the provider layer
|
||||||
|
- [`docs/guide_rag.md`](guide_rag.md#data-oriented-error-handling-fleury-pattern) — same pattern in the RAG engine
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## See Also
|
## See Also
|
||||||
|
|||||||
Reference in New Issue
Block a user