milo-cli 0.1.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.
milo/schema.py ADDED
@@ -0,0 +1,214 @@
1
+ """Function signature → JSON Schema for MCP tool compatibility."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import dataclasses
6
+ import enum
7
+ import inspect
8
+ import types
9
+ import typing
10
+ from collections.abc import Callable
11
+ from typing import Any, Literal, Union, get_args, get_origin
12
+
13
+ _TYPE_MAP: dict[type, str] = {
14
+ str: "string",
15
+ int: "integer",
16
+ float: "number",
17
+ bool: "boolean",
18
+ }
19
+
20
+
21
+ def function_to_schema(func: Callable[..., Any]) -> dict[str, Any]:
22
+ """Generate MCP-compatible JSON Schema from function type annotations.
23
+
24
+ Parameters with defaults are optional (not in required).
25
+ X | None unions unwrapped to base type.
26
+ Supports: str, int, float, bool, list[X], dict[str, X], X | None,
27
+ Enum, Literal, dataclass, TypedDict, Union.
28
+ """
29
+ sig = inspect.signature(func)
30
+ # Resolve string annotations (from __future__ import annotations)
31
+ try:
32
+ hints = typing.get_type_hints(func)
33
+ except Exception:
34
+ hints = {}
35
+
36
+ properties: dict[str, Any] = {}
37
+ required: list[str] = []
38
+
39
+ for name, param in sig.parameters.items():
40
+ annotation = hints.get(name, param.annotation)
41
+ if annotation is inspect.Parameter.empty:
42
+ annotation = str
43
+
44
+ # Strip return type
45
+ if name == "return":
46
+ continue
47
+
48
+ # Skip Context parameters (injected by CLI dispatcher)
49
+ if _is_context_type(annotation, name):
50
+ continue
51
+
52
+ is_optional = _is_optional(annotation)
53
+ if is_optional:
54
+ annotation = _unwrap_optional(annotation)
55
+
56
+ properties[name] = _type_to_schema(annotation)
57
+
58
+ has_default = param.default is not inspect.Parameter.empty
59
+ if not has_default and not is_optional:
60
+ required.append(name)
61
+
62
+ result: dict[str, Any] = {"type": "object", "properties": properties}
63
+ if required:
64
+ result["required"] = required
65
+ return result
66
+
67
+
68
+ def _type_to_schema(annotation: Any, _seen: set[int] | None = None) -> dict[str, Any]:
69
+ """Convert Python type annotation to JSON Schema fragment."""
70
+ # Primitive types
71
+ if annotation in _TYPE_MAP:
72
+ return {"type": _TYPE_MAP[annotation]}
73
+
74
+ # Handle bare dict/list (unparameterized)
75
+ if annotation is dict:
76
+ return {"type": "object"}
77
+ if annotation is list:
78
+ return {"type": "array"}
79
+
80
+ # Enum subclass
81
+ if isinstance(annotation, type) and issubclass(annotation, enum.Enum):
82
+ values = [m.value for m in annotation]
83
+ if all(isinstance(v, int) for v in values):
84
+ return {"type": "integer", "enum": values}
85
+ return {"type": "string", "enum": values}
86
+
87
+ # Literal
88
+ origin = get_origin(annotation)
89
+ if origin is Literal:
90
+ return {"enum": list(get_args(annotation))}
91
+
92
+ # dataclass
93
+ if dataclasses.is_dataclass(annotation) and isinstance(annotation, type):
94
+ if _seen is None:
95
+ _seen = set()
96
+ type_id = id(annotation)
97
+ if type_id in _seen:
98
+ return {"type": "object"} # cycle detected
99
+ _seen = _seen | {type_id}
100
+ props = {}
101
+ req = []
102
+ hints = typing.get_type_hints(annotation)
103
+ for f in dataclasses.fields(annotation):
104
+ field_type = hints.get(f.name, str)
105
+ props[f.name] = _type_to_schema(field_type, _seen)
106
+ if f.default is dataclasses.MISSING and f.default_factory is dataclasses.MISSING:
107
+ req.append(f.name)
108
+ result: dict[str, Any] = {"type": "object", "properties": props}
109
+ if req:
110
+ result["required"] = req
111
+ return result
112
+
113
+ # TypedDict
114
+ if _is_typed_dict(annotation):
115
+ if _seen is None:
116
+ _seen = set()
117
+ type_id = id(annotation)
118
+ if type_id in _seen:
119
+ return {"type": "object"}
120
+ _seen = _seen | {type_id}
121
+ hints = typing.get_type_hints(annotation)
122
+ props = {}
123
+ for fname, ftype in hints.items():
124
+ props[fname] = _type_to_schema(ftype, _seen)
125
+ result = {"type": "object", "properties": props}
126
+ # TypedDict required keys
127
+ req_keys = getattr(annotation, "__required_keys__", set())
128
+ if req_keys:
129
+ result["required"] = sorted(req_keys)
130
+ return result
131
+
132
+ # Union (non-Optional, non-None)
133
+ if origin is Union or origin is types.UnionType:
134
+ args = get_args(annotation)
135
+ non_none = [a for a in args if a is not type(None)]
136
+ if len(non_none) > 1:
137
+ if _seen is None:
138
+ _seen = set()
139
+ return {"anyOf": [_type_to_schema(a, _seen) for a in non_none]}
140
+ if len(non_none) == 1:
141
+ return _type_to_schema(non_none[0], _seen)
142
+
143
+ # list[T] with recursive item schema
144
+ if origin is list:
145
+ args = get_args(annotation)
146
+ if args:
147
+ return {"type": "array", "items": _type_to_schema(args[0], _seen)}
148
+ return {"type": "array"}
149
+
150
+ # dict[str, V] with additionalProperties
151
+ if origin is dict:
152
+ args = get_args(annotation)
153
+ if args and len(args) == 2:
154
+ return {"type": "object", "additionalProperties": _type_to_schema(args[1], _seen)}
155
+ return {"type": "object"}
156
+
157
+ return {"type": "string"}
158
+
159
+
160
+ def _is_typed_dict(annotation: Any) -> bool:
161
+ """Check if annotation is a TypedDict subclass."""
162
+ return (
163
+ isinstance(annotation, type)
164
+ and issubclass(annotation, dict)
165
+ and hasattr(annotation, "__annotations__")
166
+ and hasattr(annotation, "__required_keys__")
167
+ )
168
+
169
+
170
+ def _is_optional(annotation: Any) -> bool:
171
+ """Check if annotation is X | None."""
172
+ origin = get_origin(annotation)
173
+ if origin is Union or origin is types.UnionType:
174
+ return type(None) in get_args(annotation)
175
+ return False
176
+
177
+
178
+ def _unwrap_optional(annotation: Any) -> Any:
179
+ """Extract non-None type from X | None."""
180
+ args = get_args(annotation)
181
+ non_none = [a for a in args if a is not type(None)]
182
+ return non_none[0] if len(non_none) == 1 else str
183
+
184
+
185
+ def return_to_schema(func: Callable[..., Any]) -> dict[str, Any] | None:
186
+ """Generate JSON Schema from function return type annotation.
187
+
188
+ Returns None if the function has no return annotation or returns None.
189
+ """
190
+ try:
191
+ hints = typing.get_type_hints(func)
192
+ except Exception:
193
+ hints = {}
194
+
195
+ ret = hints.get("return", inspect.Parameter.empty)
196
+ if ret is inspect.Parameter.empty or ret is type(None):
197
+ return None
198
+
199
+ # Unwrap Optional return types
200
+ if _is_optional(ret):
201
+ ret = _unwrap_optional(ret)
202
+ if ret is type(None):
203
+ return None
204
+
205
+ return _type_to_schema(ret)
206
+
207
+
208
+ def _is_context_type(annotation: Any, name: str) -> bool:
209
+ """Check if an annotation refers to milo's Context type."""
210
+ if name == "ctx":
211
+ return True
212
+ if isinstance(annotation, type) and annotation.__name__ == "Context":
213
+ return True
214
+ return isinstance(annotation, str) and annotation in ("Context", "milo.context.Context")
milo/state.py ADDED
@@ -0,0 +1,229 @@
1
+ """Store, dispatch, saga runner, combine_reducers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import threading
7
+ import time
8
+ from collections.abc import Callable
9
+ from concurrent.futures import ThreadPoolExecutor
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from milo._errors import ErrorCode, StateError
14
+ from milo._types import (
15
+ Action,
16
+ Call,
17
+ Delay,
18
+ Fork,
19
+ Put,
20
+ Quit,
21
+ ReducerResult,
22
+ Select,
23
+ )
24
+
25
+
26
+ class Store:
27
+ """Centralized state container with saga support.
28
+
29
+ Thread-safety: reads are lock-free (frozen state).
30
+ Dispatch serializes through a lock.
31
+ Sagas run on a ThreadPoolExecutor.
32
+ """
33
+
34
+ def __init__(
35
+ self,
36
+ reducer: Callable,
37
+ initial_state: Any,
38
+ middleware: tuple[Callable, ...] = (),
39
+ *,
40
+ record: bool | str | Path = False,
41
+ ) -> None:
42
+ self._reducer = reducer
43
+ self._state = initial_state
44
+ self._lock = threading.Lock()
45
+ self._listeners: list[Callable] = []
46
+ self._executor = ThreadPoolExecutor(max_workers=4)
47
+ self._recording: list[dict] | None = [] if record else None
48
+ self._record_path = record if isinstance(record, (str, Path)) else None
49
+ self._quit = threading.Event()
50
+ self._exit_code = 0
51
+
52
+ # Build middleware chain
53
+ self._dispatch_fn = self._base_dispatch
54
+ for mw in reversed(middleware):
55
+ self._dispatch_fn = mw(self._dispatch_fn, self._get_state)
56
+
57
+ # Initialize
58
+ self.dispatch(Action("@@INIT"))
59
+
60
+ def _get_state(self) -> Any:
61
+ return self._state
62
+
63
+ @property
64
+ def state(self) -> Any:
65
+ return self._state
66
+
67
+ def dispatch(self, action: Action) -> None:
68
+ """Dispatch action through middleware -> reducer."""
69
+ self._dispatch_fn(action)
70
+
71
+ def _base_dispatch(self, action: Action) -> None:
72
+ """Core dispatch: reducer + saga scheduling + recording."""
73
+ quit_signal = False
74
+
75
+ with self._lock:
76
+ try:
77
+ result = self._reducer(self._state, action)
78
+ except Exception as e:
79
+ raise StateError(ErrorCode.STA_REDUCER, f"Reducer error: {e}") from e
80
+
81
+ sagas = ()
82
+
83
+ # Unwrap Quit — may wrap a ReducerResult or plain state
84
+ if isinstance(result, Quit):
85
+ quit_signal = True
86
+ self._exit_code = result.code
87
+ sagas = result.sagas
88
+ result = result.state
89
+
90
+ # Unwrap ReducerResult
91
+ if isinstance(result, ReducerResult):
92
+ self._state = result.state
93
+ sagas = sagas + result.sagas
94
+ else:
95
+ self._state = result
96
+
97
+ # Record
98
+ if self._recording is not None:
99
+ state_hash = hashlib.sha256(repr(self._state).encode()).hexdigest()[:16]
100
+ self._recording.append(
101
+ {
102
+ "timestamp": time.time(),
103
+ "action_type": action.type,
104
+ "action_payload": action.payload,
105
+ "state_hash": state_hash,
106
+ }
107
+ )
108
+
109
+ # Notify listeners
110
+ for listener in self._listeners:
111
+ listener()
112
+
113
+ # Schedule sagas outside the lock
114
+ for saga_fn in sagas:
115
+ self.run_saga(saga_fn())
116
+
117
+ # Set quit after sagas are scheduled and listeners notified
118
+ if quit_signal:
119
+ self._quit.set()
120
+
121
+ def run_saga(self, saga: Any) -> None:
122
+ """Schedule a saga on the thread pool."""
123
+ self._executor.submit(self._run_saga, saga)
124
+
125
+ def _run_saga(self, saga: Any) -> None:
126
+ """Step through a generator saga, executing effects."""
127
+ try:
128
+ effect = next(saga)
129
+ while True:
130
+ match effect:
131
+ case Call(fn, args, kwargs):
132
+ result = fn(*args, **kwargs)
133
+ effect = saga.send(result)
134
+ case Put(action):
135
+ self.dispatch(action)
136
+ effect = next(saga)
137
+ case Select(selector):
138
+ state = self._state
139
+ if selector:
140
+ state = selector(state)
141
+ effect = saga.send(state)
142
+ case Fork(child_saga):
143
+ self._executor.submit(self._run_saga, child_saga)
144
+ effect = next(saga)
145
+ case Delay(seconds):
146
+ time.sleep(seconds)
147
+ effect = next(saga)
148
+ case _:
149
+ raise StateError(
150
+ ErrorCode.STA_SAGA,
151
+ f"Unknown effect type: {type(effect).__name__}",
152
+ )
153
+ except StopIteration:
154
+ pass
155
+ except StateError:
156
+ raise
157
+ except Exception as e:
158
+ raise StateError(ErrorCode.STA_SAGA, f"Saga error: {e}") from e
159
+
160
+ def subscribe(self, listener: Callable) -> Callable[[], None]:
161
+ """Register state-change listener. Returns unsubscribe callable."""
162
+ self._listeners.append(listener)
163
+
164
+ def unsubscribe() -> None:
165
+ self._listeners.remove(listener)
166
+
167
+ return unsubscribe
168
+
169
+ @property
170
+ def quit_requested(self) -> bool:
171
+ """True if a reducer returned Quit."""
172
+ return self._quit.is_set()
173
+
174
+ @property
175
+ def exit_code(self) -> int:
176
+ """Exit code from the Quit signal (default 0)."""
177
+ return self._exit_code
178
+
179
+ @property
180
+ def recording(self) -> list[dict] | None:
181
+ """Get session recording if enabled."""
182
+ return self._recording
183
+
184
+ def shutdown(self) -> None:
185
+ """Shut down the thread pool."""
186
+ self._executor.shutdown(wait=False)
187
+
188
+
189
+ def combine_reducers(**reducers: Callable) -> Callable:
190
+ """Combine multiple reducers into one that manages a dict state.
191
+
192
+ Each reducer manages a slice of state under its keyword name.
193
+ Sagas from ReducerResult and Quit are collected and propagated.
194
+ """
195
+
196
+ def combined(state: dict | None, action: Action) -> dict | ReducerResult | Quit:
197
+ if state is None:
198
+ state = {}
199
+ next_state = {}
200
+ changed = False
201
+ all_sagas: list[Callable] = []
202
+ quit_signal: Quit | None = None
203
+
204
+ for key, reducer in reducers.items():
205
+ prev = state.get(key)
206
+ next_val = reducer(prev, action)
207
+ if isinstance(next_val, Quit):
208
+ quit_signal = next_val
209
+ next_state[key] = next_val.state
210
+ all_sagas.extend(next_val.sagas)
211
+ changed = True
212
+ elif isinstance(next_val, ReducerResult):
213
+ next_state[key] = next_val.state
214
+ all_sagas.extend(next_val.sagas)
215
+ changed = True
216
+ else:
217
+ next_state[key] = next_val
218
+ if next_state[key] is not prev:
219
+ changed = True
220
+
221
+ result = next_state if changed else state
222
+
223
+ if quit_signal is not None:
224
+ return Quit(state=result, code=quit_signal.code, sagas=tuple(all_sagas))
225
+ if all_sagas:
226
+ return ReducerResult(state=result, sagas=tuple(all_sagas))
227
+ return result
228
+
229
+ return combined
milo/streaming.py ADDED
@@ -0,0 +1,41 @@
1
+ """Streaming support for long-running CLI commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import inspect
6
+ from dataclasses import dataclass
7
+ from typing import Any
8
+
9
+
10
+ @dataclass(frozen=True, slots=True)
11
+ class Progress:
12
+ """Progress notification from a streaming command."""
13
+
14
+ status: str
15
+ step: int = 0
16
+ total: int = 0
17
+
18
+
19
+ def consume_generator(gen: Any) -> tuple[list[Progress], Any]:
20
+ """Consume a generator, collecting Progress yields and capturing the final value.
21
+
22
+ Returns ``(progress_list, final_value)``.
23
+ If the generator has no ``StopIteration.value``, final_value is ``None``.
24
+ """
25
+ progress: list[Progress] = []
26
+ final_value = None
27
+
28
+ try:
29
+ while True:
30
+ value = next(gen)
31
+ if isinstance(value, Progress):
32
+ progress.append(value)
33
+ except StopIteration as e:
34
+ final_value = e.value
35
+
36
+ return progress, final_value
37
+
38
+
39
+ def is_generator_result(result: Any) -> bool:
40
+ """Check if a call result is a generator that should be consumed for streaming."""
41
+ return inspect.isgenerator(result)
@@ -0,0 +1,38 @@
1
+ """Built-in templates and template environment factory."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ _TEMPLATE_DIR = Path(__file__).parent
9
+
10
+
11
+ def get_env(**kwargs: Any) -> Any:
12
+ """Create a kida Environment with the built-in template loader.
13
+
14
+ Returns a kida Environment with a chained loader:
15
+ user templates -> milo built-in templates -> kida components.
16
+ """
17
+ from kida import Environment, FileSystemLoader
18
+
19
+ loaders = []
20
+
21
+ # User-provided loader
22
+ if "loader" in kwargs:
23
+ user_loader = kwargs.pop("loader")
24
+ if user_loader is not None:
25
+ loaders.append(user_loader)
26
+
27
+ # Built-in milo templates
28
+ loaders.append(FileSystemLoader(str(_TEMPLATE_DIR)))
29
+
30
+ if len(loaders) == 1:
31
+ loader = loaders[0]
32
+ else:
33
+ from kida import ChoiceLoader
34
+
35
+ loader = ChoiceLoader(loaders)
36
+
37
+ kwargs.setdefault("autoescape", "terminal")
38
+ return Environment(loader=loader, **kwargs)
@@ -0,0 +1,5 @@
1
+ {{ "error" | red | bold }}{% if code %} {{ code | yellow }}{% endif %}
2
+ {{ error | dim }}
3
+ {% if template_name %} {{ "template:" | dim }} {{ template_name }}{% endif %}
4
+ {% if hint %} {{ "hint:" | cyan }} {{ hint }}{% endif %}
5
+ {% if docs_url %} {{ "docs:" | dim }} {{ docs_url }}{% endif %}
@@ -0,0 +1 @@
1
+ {% if field.focused %}{{ ">" | green | bold }} {{ spec.label | bold }}: {% if field.value %}{{ "Yes" | green | bold }}{% else %}{{ "No" | dim }}{% endif %}{% else %} {{ spec.label | dim }}: {% if field.value %}{{ "Yes" | dim }}{% else %}{{ "No" | dim }}{% endif %}{% endif %}
@@ -0,0 +1,3 @@
1
+ {% if field.focused %}{{ ">" | green | bold }} {{ spec.label | bold }}:{% else %} {{ spec.label | dim }}:{% endif %}
2
+ {% for choice in spec.choices %} {% if loop.index0 == field.selected_index %}{{ icons.check | green }} {{ choice | bold }}{% else %} {{ choice | dim }}{% endif %}
3
+ {% endfor %}
@@ -0,0 +1 @@
1
+ {% if field.focused %}{{ ">" | green | bold }} {{ spec.label | bold }}: {{ field.value }}{% else %} {{ spec.label | dim }}: {{ field.value | dim }}{% endif %}{% if field.error %} {{ field.error | red }}{% endif %}
@@ -0,0 +1,8 @@
1
+ {% for spec, field in zip(state.specs, state.fields) %}
2
+ {% if field.focused %}{{ ">" | green | bold }} {{ spec.label | bold }}{% else %} {{ spec.label | dim }}{% endif %}{% if field.error %} {{ field.error | red }}{% endif %}
3
+ {% if spec.field_type.name == "TEXT" or spec.field_type.name == "PASSWORD" %} {% if spec.field_type.name == "PASSWORD" %}{{ "*" * (field.value | length) | dim }}{% else %}{{ field.value }}{% endif %}
4
+ {% elif spec.field_type.name == "SELECT" %}{% for choice in spec.choices %}
5
+ {% if loop.index0 == field.selected_index %}{{ icons.check | green }} {{ choice | bold }}{% else %} {{ choice | dim }}{% endif %}{% endfor %}
6
+ {% elif spec.field_type.name == "CONFIRM" %} {% if field.value %}{{ "Yes" | green | bold }}{% else %}{{ "No" | dim }}{% endif %}
7
+ {% endif %}
8
+ {% endfor %}
@@ -0,0 +1,7 @@
1
+ {{ state.prog | bold }}{% if state.description %} {{ "-" | dim }} {{ state.description }}{% endif %}
2
+
3
+ {% for group in state.groups %}
4
+ {{ group.title | yellow | bold }}:
5
+ {% for action in group.actions %} {% if action.option_strings %}{{ action.option_strings | join(", ") | green }}{% else %}{{ action.dest | cyan }}{% endif %}{% if action.metavar %} {{ action.metavar | dim }}{% endif %} {{ action.help }}{% if action.default is not none and action.default != "==SUPPRESS==" %} {{ ("(default: " ~ action.default ~ ")") | dim }}{% endif %}
6
+ {% endfor %}
7
+ {% endfor %}
@@ -0,0 +1 @@
1
+ {{ state.label | default("Progress") | bold }}: {{ bar(state.progress, width=20) | green }} {{ (state.progress * 100) | int }}%
@@ -0,0 +1,27 @@
1
+ """Public test API: assert_renders, replay, record, MCP client."""
2
+
3
+ from milo.testing._mcp import CallResult, MCPClient, ToolInfo
4
+ from milo.testing._record import (
5
+ ActionRecord,
6
+ SessionRecording,
7
+ load_recording,
8
+ recording_middleware,
9
+ save_recording,
10
+ )
11
+ from milo.testing._replay import replay
12
+ from milo.testing._snapshot import assert_renders, assert_saga, assert_state
13
+
14
+ __all__ = [
15
+ "ActionRecord",
16
+ "CallResult",
17
+ "MCPClient",
18
+ "SessionRecording",
19
+ "ToolInfo",
20
+ "assert_renders",
21
+ "assert_saga",
22
+ "assert_state",
23
+ "load_recording",
24
+ "recording_middleware",
25
+ "replay",
26
+ "save_recording",
27
+ ]
milo/testing/_mcp.py ADDED
@@ -0,0 +1,87 @@
1
+ """MCP test client — synchronous wrapper around milo.mcp internals."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Any
7
+
8
+ from milo.commands import CLI
9
+ from milo.mcp import _call_tool, _handle_method, _list_tools
10
+
11
+
12
+ @dataclass(frozen=True, slots=True)
13
+ class ToolInfo:
14
+ """Frozen snapshot of an MCP tool descriptor."""
15
+
16
+ name: str
17
+ description: str
18
+ input_schema: dict[str, Any]
19
+ output_schema: dict[str, Any] | None
20
+
21
+
22
+ @dataclass(frozen=True, slots=True)
23
+ class CallResult:
24
+ """Frozen snapshot of an MCP tools/call response."""
25
+
26
+ text: str
27
+ is_error: bool
28
+ structured: Any
29
+
30
+
31
+ class MCPClient:
32
+ """Synchronous test client for exercising a CLI's MCP surface."""
33
+
34
+ def __init__(self, cli: CLI) -> None:
35
+ self._cli = cli
36
+
37
+ def initialize(self) -> dict[str, Any]:
38
+ """Send ``initialize`` and return the server info dict."""
39
+ result = _handle_method(self._cli, "initialize", {})
40
+ assert result is not None, "initialize must return a result"
41
+ return result
42
+
43
+ def list_tools(self) -> list[ToolInfo]:
44
+ """Return all visible tools as ToolInfo objects."""
45
+ raw = _list_tools(self._cli)
46
+ return [
47
+ ToolInfo(
48
+ name=t["name"],
49
+ description=t.get("description", ""),
50
+ input_schema=t.get("inputSchema", {}),
51
+ output_schema=t.get("outputSchema"),
52
+ )
53
+ for t in raw
54
+ ]
55
+
56
+ def call(self, tool_name: str, **arguments: Any) -> CallResult:
57
+ """Invoke a tool by name and return a CallResult."""
58
+ raw = _call_tool(self._cli, {"name": tool_name, "arguments": arguments})
59
+ content = raw.get("content", [])
60
+ text = content[0]["text"] if content else ""
61
+ return CallResult(
62
+ text=text,
63
+ is_error=raw.get("isError", False),
64
+ structured=raw.get("structuredContent"),
65
+ )
66
+
67
+ def list_resources(self) -> list[dict[str, Any]]:
68
+ """Return all resources (requires F3 resources support)."""
69
+ result = _handle_method(self._cli, "resources/list", {})
70
+ return result.get("resources", []) if result else []
71
+
72
+ def read_resource(self, uri: str) -> dict[str, Any]:
73
+ """Read a resource by URI (requires F3 resources support)."""
74
+ result = _handle_method(self._cli, "resources/read", {"uri": uri})
75
+ return result or {}
76
+
77
+ def list_prompts(self) -> list[dict[str, Any]]:
78
+ """Return all prompts (requires F3 prompts support)."""
79
+ result = _handle_method(self._cli, "prompts/list", {})
80
+ return result.get("prompts", []) if result else []
81
+
82
+ def get_prompt(self, prompt_name: str, **arguments: Any) -> dict[str, Any]:
83
+ """Get a prompt by name (requires F3 prompts support)."""
84
+ result = _handle_method(
85
+ self._cli, "prompts/get", {"name": prompt_name, "arguments": arguments}
86
+ )
87
+ return result or {}