docent-cli 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- docent/__init__.py +1 -0
- docent/bundled_plugins/__init__.py +0 -0
- docent/bundled_plugins/reading/__init__.py +1205 -0
- docent/bundled_plugins/reading/mendeley_cache.py +183 -0
- docent/bundled_plugins/reading/mendeley_client.py +132 -0
- docent/bundled_plugins/reading/reading_notify.py +78 -0
- docent/bundled_plugins/reading/reading_store.py +105 -0
- docent/cli.py +310 -0
- docent/config/__init__.py +4 -0
- docent/config/loader.py +76 -0
- docent/config/settings.py +51 -0
- docent/core/__init__.py +19 -0
- docent/core/context.py +14 -0
- docent/core/events.py +35 -0
- docent/core/plugin_loader.py +99 -0
- docent/core/registry.py +96 -0
- docent/core/tool.py +90 -0
- docent/execution/__init__.py +3 -0
- docent/execution/executor.py +69 -0
- docent/learning/__init__.py +3 -0
- docent/learning/run_log.py +69 -0
- docent/llm/__init__.py +3 -0
- docent/llm/client.py +60 -0
- docent/mcp_server.py +187 -0
- docent/tools/__init__.py +17 -0
- docent/ui/__init__.py +3 -0
- docent/ui/console.py +28 -0
- docent/ui/theme.py +16 -0
- docent/utils/__init__.py +0 -0
- docent/utils/paths.py +36 -0
- docent/utils/prompt.py +63 -0
- docent_cli-1.0.0.dist-info/METADATA +174 -0
- docent_cli-1.0.0.dist-info/RECORD +35 -0
- docent_cli-1.0.0.dist-info/WHEEL +4 -0
- docent_cli-1.0.0.dist-info/entry_points.txt +3 -0
docent/core/registry.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TypeVar
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel
|
|
6
|
+
|
|
7
|
+
from docent.core.tool import Tool, collect_actions
|
|
8
|
+
|
|
9
|
+
_REGISTRY: dict[str, type[Tool]] = {}
|
|
10
|
+
_RESERVED_NAMES = frozenset({"list", "info", "config", "version"})
|
|
11
|
+
|
|
12
|
+
T = TypeVar("T", bound=Tool)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def register_tool(cls: type[T]) -> type[T]:
|
|
16
|
+
"""Decorator: validate a Tool subclass and add it to the registry.
|
|
17
|
+
|
|
18
|
+
Registers the *class*, not an instance. Instances are created per
|
|
19
|
+
invocation by the caller (CLI command, UI request handler, etc.) so
|
|
20
|
+
tool runs never share mutable state.
|
|
21
|
+
"""
|
|
22
|
+
if not isinstance(cls, type) or not issubclass(cls, Tool):
|
|
23
|
+
raise TypeError(
|
|
24
|
+
f"@register_tool must decorate a Tool subclass; got {cls!r}"
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
for attr in ("name", "description"):
|
|
28
|
+
if not hasattr(cls, attr):
|
|
29
|
+
raise TypeError(
|
|
30
|
+
f"Tool {cls.__name__} is missing required class attribute '{attr}'"
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
if not isinstance(cls.name, str) or not cls.name:
|
|
34
|
+
raise TypeError(
|
|
35
|
+
f"Tool {cls.__name__}.name must be a non-empty string"
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
if cls.name in _RESERVED_NAMES:
|
|
39
|
+
raise ValueError(
|
|
40
|
+
f"Tool name '{cls.name}' is reserved for a built-in CLI command; choose another"
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
actions = collect_actions(cls)
|
|
44
|
+
has_run = "run" in cls.__dict__
|
|
45
|
+
has_input_schema = cls.input_schema is not None
|
|
46
|
+
|
|
47
|
+
if actions and (has_run or has_input_schema):
|
|
48
|
+
raise TypeError(
|
|
49
|
+
f"Tool {cls.__name__} declares both @action methods and run()/input_schema. "
|
|
50
|
+
f"A tool must be single-action OR multi-action, not both."
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
if not actions:
|
|
54
|
+
if not has_run:
|
|
55
|
+
raise TypeError(
|
|
56
|
+
f"Tool {cls.__name__} must define run() or at least one @action method"
|
|
57
|
+
)
|
|
58
|
+
if not has_input_schema:
|
|
59
|
+
raise TypeError(
|
|
60
|
+
f"Tool {cls.__name__}.input_schema must be set for single-action tools"
|
|
61
|
+
)
|
|
62
|
+
if not (isinstance(cls.input_schema, type) and issubclass(cls.input_schema, BaseModel)):
|
|
63
|
+
raise TypeError(
|
|
64
|
+
f"Tool {cls.__name__}.input_schema must be a Pydantic BaseModel subclass"
|
|
65
|
+
)
|
|
66
|
+
else:
|
|
67
|
+
for cli_name, (method_name, meta) in actions.items():
|
|
68
|
+
if not (
|
|
69
|
+
isinstance(meta.input_schema, type)
|
|
70
|
+
and issubclass(meta.input_schema, BaseModel)
|
|
71
|
+
):
|
|
72
|
+
raise TypeError(
|
|
73
|
+
f"Tool {cls.__name__}.{method_name}: @action input_schema must be a "
|
|
74
|
+
f"Pydantic BaseModel subclass"
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
if cls.name in _REGISTRY:
|
|
78
|
+
existing = _REGISTRY[cls.name]
|
|
79
|
+
raise ValueError(
|
|
80
|
+
f"Tool name '{cls.name}' is already registered by "
|
|
81
|
+
f"{existing.__module__}.{existing.__name__}; cannot re-register "
|
|
82
|
+
f"from {cls.__module__}.{cls.__name__}"
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
_REGISTRY[cls.name] = cls
|
|
86
|
+
return cls
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def get_tool(name: str) -> type[Tool]:
|
|
90
|
+
if name not in _REGISTRY:
|
|
91
|
+
raise KeyError(f"No tool registered with name '{name}'")
|
|
92
|
+
return _REGISTRY[name]
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def all_tools() -> dict[str, type[Tool]]:
|
|
96
|
+
return dict(_REGISTRY)
|
docent/core/tool.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from abc import ABC
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Any, Callable, ClassVar, TypeVar
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel
|
|
8
|
+
|
|
9
|
+
from docent.core.context import Context
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class Action:
|
|
14
|
+
"""Metadata attached to a method by `@action`.
|
|
15
|
+
|
|
16
|
+
The registry introspects each Tool class for methods carrying an `Action`
|
|
17
|
+
via `_docent_action`. CLI name defaults to the method name with
|
|
18
|
+
underscores → dashes (override via `name=`).
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
description: str
|
|
22
|
+
input_schema: type[BaseModel]
|
|
23
|
+
name: str | None = None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
F = TypeVar("F", bound=Callable[..., Any])
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def action(
|
|
30
|
+
*,
|
|
31
|
+
description: str,
|
|
32
|
+
input_schema: type[BaseModel],
|
|
33
|
+
name: str | None = None,
|
|
34
|
+
) -> Callable[[F], F]:
|
|
35
|
+
"""Decorator: mark a method on a Tool as an invocable action."""
|
|
36
|
+
|
|
37
|
+
def decorator(fn: F) -> F:
|
|
38
|
+
fn._docent_action = Action( # type: ignore[attr-defined]
|
|
39
|
+
description=description, input_schema=input_schema, name=name
|
|
40
|
+
)
|
|
41
|
+
return fn
|
|
42
|
+
|
|
43
|
+
return decorator
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class Tool(ABC):
|
|
47
|
+
"""Base class for every Docent tool.
|
|
48
|
+
|
|
49
|
+
Two paths:
|
|
50
|
+
- **Single-action**: set `input_schema` and override `run()`.
|
|
51
|
+
- **Multi-action**: decorate one or more methods with `@action(...)`.
|
|
52
|
+
|
|
53
|
+
A tool must be one or the other - never both. Enforced at registration.
|
|
54
|
+
Metadata lives as class attributes so the registry (and later the UI)
|
|
55
|
+
can read it without constructing an instance.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
name: ClassVar[str]
|
|
59
|
+
description: ClassVar[str]
|
|
60
|
+
category: ClassVar[str | None] = None
|
|
61
|
+
|
|
62
|
+
input_schema: ClassVar[type[BaseModel] | None] = None
|
|
63
|
+
|
|
64
|
+
def run(self, inputs: BaseModel, context: Context) -> Any:
|
|
65
|
+
"""Single-action entry point. Multi-action tools don't override this."""
|
|
66
|
+
raise NotImplementedError(
|
|
67
|
+
f"{type(self).__name__}.run() not implemented - "
|
|
68
|
+
f"is this a multi-action tool? Invoke one of its @action methods instead."
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def collect_actions(cls: type[Tool]) -> dict[str, tuple[str, Action]]:
|
|
73
|
+
"""Return `{cli_action_name: (method_name, Action)}` for all `@action` methods.
|
|
74
|
+
|
|
75
|
+
Raises `ValueError` on CLI-name collisions between actions.
|
|
76
|
+
"""
|
|
77
|
+
found: dict[str, tuple[str, Action]] = {}
|
|
78
|
+
for attr_name in dir(cls):
|
|
79
|
+
attr = getattr(cls, attr_name, None)
|
|
80
|
+
if callable(attr) and hasattr(attr, "_docent_action"):
|
|
81
|
+
meta: Action = attr._docent_action # type: ignore[attr-defined]
|
|
82
|
+
cli_name = meta.name or attr_name.replace("_", "-")
|
|
83
|
+
if cli_name in found:
|
|
84
|
+
prev_method = found[cli_name][0]
|
|
85
|
+
raise ValueError(
|
|
86
|
+
f"Tool {cls.__name__} has two actions with the same CLI name "
|
|
87
|
+
f"'{cli_name}': {prev_method} and {attr_name}"
|
|
88
|
+
)
|
|
89
|
+
found[cli_name] = (attr_name, meta)
|
|
90
|
+
return found
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
import time
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class ProcessResult:
|
|
11
|
+
args: list[str]
|
|
12
|
+
returncode: int
|
|
13
|
+
stdout: str
|
|
14
|
+
stderr: str
|
|
15
|
+
duration: float
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ProcessExecutionError(RuntimeError):
|
|
19
|
+
"""Raised by `Executor.run` when `check=True` and the process exits non-zero."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, result: ProcessResult) -> None:
|
|
22
|
+
self.result = result
|
|
23
|
+
super().__init__(
|
|
24
|
+
f"Command {result.args!r} exited with {result.returncode} "
|
|
25
|
+
f"after {result.duration:.2f}s"
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Executor:
|
|
30
|
+
"""Run external commands and return structured results.
|
|
31
|
+
|
|
32
|
+
Commands are always given as `list[str]` - never a shell string - so there
|
|
33
|
+
is no shell-injection surface. For tools that truly need shell features,
|
|
34
|
+
pass `["bash", "-c", "..."]` (or `["cmd", "/c", "..."]`) explicitly.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def run(
|
|
38
|
+
self,
|
|
39
|
+
args: list[str],
|
|
40
|
+
*,
|
|
41
|
+
timeout: float | None = None,
|
|
42
|
+
cwd: Path | str | None = None,
|
|
43
|
+
env: dict[str, str] | None = None,
|
|
44
|
+
check: bool = True,
|
|
45
|
+
) -> ProcessResult:
|
|
46
|
+
start = time.perf_counter()
|
|
47
|
+
completed = subprocess.run(
|
|
48
|
+
args,
|
|
49
|
+
capture_output=True,
|
|
50
|
+
text=True,
|
|
51
|
+
timeout=timeout,
|
|
52
|
+
cwd=cwd,
|
|
53
|
+
env=env,
|
|
54
|
+
check=False,
|
|
55
|
+
)
|
|
56
|
+
duration = time.perf_counter() - start
|
|
57
|
+
|
|
58
|
+
result = ProcessResult(
|
|
59
|
+
args=list(args),
|
|
60
|
+
returncode=completed.returncode,
|
|
61
|
+
stdout=completed.stdout,
|
|
62
|
+
stderr=completed.stderr,
|
|
63
|
+
duration=duration,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
if check and completed.returncode != 0:
|
|
67
|
+
raise ProcessExecutionError(result)
|
|
68
|
+
|
|
69
|
+
return result
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Per-namespace run-log helpers.
|
|
2
|
+
|
|
3
|
+
A `RunLog(namespace)` is a JSONL file at `~/.docent/data/<namespace>/run-log.jsonl`
|
|
4
|
+
capped at `max_lines` (default 50). Append is cheap (single line write); tail is
|
|
5
|
+
cheap (read + split); rollover rewrites atomically when the cap is hit.
|
|
6
|
+
|
|
7
|
+
Entries are free-form JSON dicts. A `timestamp` field is auto-added on append
|
|
8
|
+
if the caller didn't supply one.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
from datetime import datetime
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from docent.utils.paths import data_dir
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class RunLog:
|
|
22
|
+
def __init__(self, namespace: str, *, max_lines: int = 50) -> None:
|
|
23
|
+
if not namespace or "/" in namespace or "\\" in namespace:
|
|
24
|
+
raise ValueError(f"Invalid namespace: {namespace!r}")
|
|
25
|
+
if max_lines < 1:
|
|
26
|
+
raise ValueError(f"max_lines must be >= 1, got {max_lines}")
|
|
27
|
+
self.namespace = namespace
|
|
28
|
+
self.max_lines = max_lines
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def path(self) -> Path:
|
|
32
|
+
return data_dir() / self.namespace / "run-log.jsonl"
|
|
33
|
+
|
|
34
|
+
def append(self, entry: dict[str, Any]) -> None:
|
|
35
|
+
record = dict(entry)
|
|
36
|
+
record.setdefault("timestamp", datetime.now().isoformat())
|
|
37
|
+
line = json.dumps(record, ensure_ascii=False)
|
|
38
|
+
|
|
39
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
40
|
+
existing = self._read_lines()
|
|
41
|
+
|
|
42
|
+
if len(existing) >= self.max_lines:
|
|
43
|
+
kept = existing[-(self.max_lines - 1):] if self.max_lines > 1 else []
|
|
44
|
+
self._atomic_rewrite(kept + [line])
|
|
45
|
+
else:
|
|
46
|
+
with self.path.open("a", encoding="utf-8") as f:
|
|
47
|
+
f.write(line + "\n")
|
|
48
|
+
|
|
49
|
+
def tail(self, n: int) -> list[dict[str, Any]]:
|
|
50
|
+
if n < 0:
|
|
51
|
+
raise ValueError(f"n must be >= 0, got {n}")
|
|
52
|
+
if n == 0:
|
|
53
|
+
return []
|
|
54
|
+
lines = self._read_lines()
|
|
55
|
+
return [json.loads(line) for line in lines[-n:]]
|
|
56
|
+
|
|
57
|
+
def all(self) -> list[dict[str, Any]]:
|
|
58
|
+
return [json.loads(line) for line in self._read_lines()]
|
|
59
|
+
|
|
60
|
+
def _read_lines(self) -> list[str]:
|
|
61
|
+
if not self.path.exists():
|
|
62
|
+
return []
|
|
63
|
+
text = self.path.read_text(encoding="utf-8")
|
|
64
|
+
return [line for line in text.splitlines() if line.strip()]
|
|
65
|
+
|
|
66
|
+
def _atomic_rewrite(self, lines: list[str]) -> None:
|
|
67
|
+
tmp = self.path.with_suffix(self.path.suffix + ".tmp")
|
|
68
|
+
tmp.write_text("\n".join(lines) + ("\n" if lines else ""), encoding="utf-8")
|
|
69
|
+
os.replace(tmp, self.path)
|
docent/llm/__init__.py
ADDED
docent/llm/client.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
from docent.config import Settings
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class LLMResponse:
|
|
11
|
+
text: str
|
|
12
|
+
model: str
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class LLMClient:
|
|
16
|
+
"""Thin wrapper around litellm. The single entry point for model calls.
|
|
17
|
+
|
|
18
|
+
litellm is imported lazily inside `complete()` so meta-commands
|
|
19
|
+
(`--version`, `list`, `info`) never pay the ~1s import cost.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(self, settings: Settings) -> None:
|
|
23
|
+
self.settings = settings
|
|
24
|
+
# Propagate config-provided API keys into env if the env var is unset.
|
|
25
|
+
# Env vars set by the user always win.
|
|
26
|
+
if settings.anthropic_api_key and not os.environ.get("ANTHROPIC_API_KEY"):
|
|
27
|
+
os.environ["ANTHROPIC_API_KEY"] = settings.anthropic_api_key
|
|
28
|
+
if settings.openai_api_key and not os.environ.get("OPENAI_API_KEY"):
|
|
29
|
+
os.environ["OPENAI_API_KEY"] = settings.openai_api_key
|
|
30
|
+
|
|
31
|
+
def complete(
|
|
32
|
+
self,
|
|
33
|
+
prompt: str,
|
|
34
|
+
*,
|
|
35
|
+
system: str | None = None,
|
|
36
|
+
model: str | None = None,
|
|
37
|
+
temperature: float = 0.7,
|
|
38
|
+
max_tokens: int | None = None,
|
|
39
|
+
) -> LLMResponse:
|
|
40
|
+
import litellm
|
|
41
|
+
|
|
42
|
+
messages: list[dict[str, str]] = []
|
|
43
|
+
if system:
|
|
44
|
+
messages.append({"role": "system", "content": system})
|
|
45
|
+
messages.append({"role": "user", "content": prompt})
|
|
46
|
+
|
|
47
|
+
kwargs: dict[str, object] = {}
|
|
48
|
+
if max_tokens is not None:
|
|
49
|
+
kwargs["max_tokens"] = max_tokens
|
|
50
|
+
|
|
51
|
+
response = litellm.completion(
|
|
52
|
+
model=model or self.settings.default_model,
|
|
53
|
+
messages=messages,
|
|
54
|
+
temperature=temperature,
|
|
55
|
+
num_retries=2,
|
|
56
|
+
**kwargs,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
text = response.choices[0].message.content or ""
|
|
60
|
+
return LLMResponse(text=text, model=response.model)
|
docent/mcp_server.py
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""Step 13 — Full MCP adapter.
|
|
2
|
+
|
|
3
|
+
Exposes every registered Docent action as an MCP tool callable from Claude Code
|
|
4
|
+
(or any other MCP-compatible client) over stdio.
|
|
5
|
+
|
|
6
|
+
Tool naming convention: `{tool_name}__{action_name}` where the action's CLI name
|
|
7
|
+
has hyphens replaced by underscores.
|
|
8
|
+
|
|
9
|
+
Example: reading tool's `sync-from-mendeley` → `reading__sync_from_mendeley`
|
|
10
|
+
|
|
11
|
+
The double-underscore separator is unambiguous: tool names are single-word
|
|
12
|
+
identifiers (`reading`, `paper`) and action names never contain `__`.
|
|
13
|
+
|
|
14
|
+
Usage:
|
|
15
|
+
docent serve # recommended — loads plugins first
|
|
16
|
+
|
|
17
|
+
Claude Code .mcp.json:
|
|
18
|
+
{
|
|
19
|
+
"mcpServers": {
|
|
20
|
+
"docent": {
|
|
21
|
+
"command": "uv",
|
|
22
|
+
"args": ["--directory", "<project-root>", "run", "docent", "serve"]
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
"""
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import asyncio
|
|
30
|
+
import inspect
|
|
31
|
+
import json
|
|
32
|
+
from typing import Any
|
|
33
|
+
|
|
34
|
+
from mcp import types
|
|
35
|
+
from mcp.server import Server
|
|
36
|
+
from mcp.server.stdio import stdio_server
|
|
37
|
+
from pydantic import BaseModel
|
|
38
|
+
|
|
39
|
+
from docent.config import load_settings
|
|
40
|
+
from docent.core import (
|
|
41
|
+
Context,
|
|
42
|
+
ProgressEvent,
|
|
43
|
+
all_tools,
|
|
44
|
+
collect_actions,
|
|
45
|
+
load_plugins,
|
|
46
|
+
)
|
|
47
|
+
from docent.execution import Executor
|
|
48
|
+
from docent.llm import LLMClient
|
|
49
|
+
from docent.tools import discover_tools
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# ---------------------------------------------------------------------------
|
|
53
|
+
# Naming helpers
|
|
54
|
+
# ---------------------------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
def mcp_tool_name(tool_name: str, action_cli_name: str) -> str:
|
|
57
|
+
"""Build the MCP tool name from a (tool, action) pair."""
|
|
58
|
+
return f"{tool_name}__{action_cli_name.replace('-', '_')}"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def parse_mcp_tool_name(mcp_name: str) -> tuple[str, str] | None:
|
|
62
|
+
"""Return (tool_name, action_cli_name) or None if the name is not a Docent tool."""
|
|
63
|
+
if "__" not in mcp_name:
|
|
64
|
+
return None
|
|
65
|
+
tool_name, action_part = mcp_name.split("__", 1)
|
|
66
|
+
action_cli_name = action_part.replace("_", "-")
|
|
67
|
+
return tool_name, action_cli_name
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# ---------------------------------------------------------------------------
|
|
71
|
+
# Registry introspection
|
|
72
|
+
# ---------------------------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
def build_mcp_tools() -> list[types.Tool]:
|
|
75
|
+
"""Return one MCP Tool descriptor per (tool, action) pair in the registry."""
|
|
76
|
+
result = []
|
|
77
|
+
for tool_name, tool_cls in sorted(all_tools().items()):
|
|
78
|
+
for action_cli_name, (_method, meta) in sorted(collect_actions(tool_cls).items()):
|
|
79
|
+
result.append(
|
|
80
|
+
types.Tool(
|
|
81
|
+
name=mcp_tool_name(tool_name, action_cli_name),
|
|
82
|
+
description=f"[{tool_name}] {meta.description}",
|
|
83
|
+
inputSchema=meta.input_schema.model_json_schema(),
|
|
84
|
+
)
|
|
85
|
+
)
|
|
86
|
+
return result
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# ---------------------------------------------------------------------------
|
|
90
|
+
# Action invocation
|
|
91
|
+
# ---------------------------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
def _make_context() -> Context:
|
|
94
|
+
settings = load_settings()
|
|
95
|
+
return Context(settings=settings, llm=LLMClient(settings), executor=Executor())
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _serialize(result: Any) -> str:
|
|
99
|
+
if isinstance(result, BaseModel):
|
|
100
|
+
return result.model_dump_json(indent=2)
|
|
101
|
+
try:
|
|
102
|
+
return json.dumps(result, indent=2, default=str)
|
|
103
|
+
except Exception:
|
|
104
|
+
return str(result)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def invoke_action(
|
|
108
|
+
tool_name: str,
|
|
109
|
+
action_cli_name: str,
|
|
110
|
+
arguments: dict[str, Any],
|
|
111
|
+
) -> str:
|
|
112
|
+
"""Run one Docent action and return its result as a JSON string.
|
|
113
|
+
|
|
114
|
+
Generator actions (streaming) collect ProgressEvent messages as a
|
|
115
|
+
prefix and the final return value as the last line.
|
|
116
|
+
"""
|
|
117
|
+
tools = all_tools()
|
|
118
|
+
if tool_name not in tools:
|
|
119
|
+
raise ValueError(f"No tool named '{tool_name}'")
|
|
120
|
+
|
|
121
|
+
tool_cls = tools[tool_name]
|
|
122
|
+
actions = collect_actions(tool_cls)
|
|
123
|
+
if action_cli_name not in actions:
|
|
124
|
+
raise ValueError(f"Tool '{tool_name}' has no action '{action_cli_name}'")
|
|
125
|
+
|
|
126
|
+
method_name, meta = actions[action_cli_name]
|
|
127
|
+
inputs = meta.input_schema(**arguments)
|
|
128
|
+
ctx = _make_context()
|
|
129
|
+
method = getattr(tool_cls(), method_name)
|
|
130
|
+
raw = method(inputs, ctx)
|
|
131
|
+
|
|
132
|
+
if inspect.isgenerator(raw):
|
|
133
|
+
lines: list[str] = []
|
|
134
|
+
try:
|
|
135
|
+
while True:
|
|
136
|
+
evt = next(raw)
|
|
137
|
+
if isinstance(evt, ProgressEvent) and evt.message:
|
|
138
|
+
lines.append(f"[{evt.phase}] {evt.message}")
|
|
139
|
+
except StopIteration as stop:
|
|
140
|
+
lines.append(_serialize(stop.value))
|
|
141
|
+
return "\n".join(lines)
|
|
142
|
+
|
|
143
|
+
return _serialize(raw)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
# ---------------------------------------------------------------------------
|
|
147
|
+
# MCP server
|
|
148
|
+
# ---------------------------------------------------------------------------
|
|
149
|
+
|
|
150
|
+
def run_server() -> None:
|
|
151
|
+
"""Load plugins, build the MCP tool list, and serve over stdio.
|
|
152
|
+
|
|
153
|
+
Called by `docent serve`. Blocks until the client disconnects.
|
|
154
|
+
"""
|
|
155
|
+
import sys
|
|
156
|
+
discover_tools()
|
|
157
|
+
load_plugins()
|
|
158
|
+
tools = build_mcp_tools()
|
|
159
|
+
print(f"[docent] MCP server ready — {len(tools)} tools registered. Waiting for client…", file=sys.stderr, flush=True)
|
|
160
|
+
|
|
161
|
+
server = Server("docent")
|
|
162
|
+
|
|
163
|
+
@server.list_tools()
|
|
164
|
+
async def list_tools() -> list[types.Tool]:
|
|
165
|
+
return build_mcp_tools()
|
|
166
|
+
|
|
167
|
+
@server.call_tool()
|
|
168
|
+
async def call_tool(name: str, arguments: dict[str, Any]) -> list[types.TextContent]:
|
|
169
|
+
parsed = parse_mcp_tool_name(name)
|
|
170
|
+
if parsed is None:
|
|
171
|
+
return [types.TextContent(type="text", text=f"Unknown tool format: {name!r}")]
|
|
172
|
+
tool_name, action_cli_name = parsed
|
|
173
|
+
try:
|
|
174
|
+
text = invoke_action(tool_name, action_cli_name, arguments or {})
|
|
175
|
+
return [types.TextContent(type="text", text=text)]
|
|
176
|
+
except Exception as exc:
|
|
177
|
+
return [types.TextContent(type="text", text=f"Error: {exc}")]
|
|
178
|
+
|
|
179
|
+
async def _serve() -> None:
|
|
180
|
+
async with stdio_server() as (read_stream, write_stream):
|
|
181
|
+
await server.run(
|
|
182
|
+
read_stream,
|
|
183
|
+
write_stream,
|
|
184
|
+
server.create_initialization_options(),
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
asyncio.run(_serve())
|
docent/tools/__init__.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import importlib
|
|
4
|
+
import pkgutil
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def discover_tools() -> None:
|
|
8
|
+
"""Import every module in `docent.tools` so `@register_tool` fires.
|
|
9
|
+
|
|
10
|
+
Each `.py` in this package is treated as a tool module. Names beginning
|
|
11
|
+
with `_` are skipped so tests and scratch modules can coexist without
|
|
12
|
+
being auto-loaded.
|
|
13
|
+
"""
|
|
14
|
+
for _, name, _ in pkgutil.iter_modules(__path__):
|
|
15
|
+
if name.startswith("_"):
|
|
16
|
+
continue
|
|
17
|
+
importlib.import_module(f"{__name__}.{name}")
|
docent/ui/__init__.py
ADDED
docent/ui/console.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from rich.console import Console
|
|
4
|
+
|
|
5
|
+
from docent.ui.theme import docent_theme
|
|
6
|
+
|
|
7
|
+
_console: Console | None = None
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def get_console() -> Console:
|
|
11
|
+
global _console
|
|
12
|
+
if _console is None:
|
|
13
|
+
_console = Console(theme=docent_theme)
|
|
14
|
+
return _console
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def configure_console(*, no_color: bool = False, quiet: bool = False) -> Console:
|
|
18
|
+
"""Replace the singleton with one matching the given flags.
|
|
19
|
+
|
|
20
|
+
Called once from the CLI root callback, after parsing global flags.
|
|
21
|
+
"""
|
|
22
|
+
global _console
|
|
23
|
+
_console = Console(
|
|
24
|
+
theme=docent_theme,
|
|
25
|
+
no_color=no_color,
|
|
26
|
+
quiet=quiet,
|
|
27
|
+
)
|
|
28
|
+
return _console
|
docent/ui/theme.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from rich.theme import Theme
|
|
4
|
+
|
|
5
|
+
docent_theme = Theme(
|
|
6
|
+
{
|
|
7
|
+
"success": "bold green",
|
|
8
|
+
"error": "bold red",
|
|
9
|
+
"warning": "yellow",
|
|
10
|
+
"info": "cyan",
|
|
11
|
+
"muted": "dim white",
|
|
12
|
+
"accent": "bold magenta",
|
|
13
|
+
"header": "bold",
|
|
14
|
+
"prompt": "bold cyan",
|
|
15
|
+
}
|
|
16
|
+
)
|
docent/utils/__init__.py
ADDED
|
File without changes
|