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/mcp.py ADDED
@@ -0,0 +1,299 @@
1
+ """MCP server — expose CLI commands as tools via JSON-RPC on stdin/stdout."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sys
7
+ from typing import TYPE_CHECKING, Any
8
+
9
+ if TYPE_CHECKING:
10
+ from milo.commands import CLI, CommandDef, LazyCommandDef
11
+
12
+ _MCP_VERSION = "2025-11-25"
13
+ _SERVER_NAME = "milo"
14
+ _SERVER_VERSION = "0.1.0"
15
+
16
+
17
+ def run_mcp_server(cli: CLI) -> None:
18
+ """Run MCP JSON-RPC server on stdin/stdout.
19
+
20
+ Implements the MCP protocol (initialize, tools/list, tools/call,
21
+ resources/list, resources/read, prompts/list, prompts/get).
22
+ """
23
+ tools = _list_tools(cli)
24
+ tool_names = [t["name"] for t in tools]
25
+
26
+ _stderr(f"MCP server ready — {cli.name}")
27
+ _stderr(f" Protocol: {_MCP_VERSION}")
28
+ _stderr(f" Tools: {len(tools)} ({', '.join(tool_names)})")
29
+ _stderr(f" Resources: {len(cli._resources)}")
30
+ _stderr(f" Prompts: {len(cli._prompts)}")
31
+ _stderr(" Transport: stdin/stdout (JSON-RPC, one request per line)")
32
+ _stderr("")
33
+ _stderr("Send requests as JSON, for example:")
34
+ _stderr(' {"jsonrpc":"2.0","id":1,"method":"initialize"}')
35
+ _stderr(' {"jsonrpc":"2.0","id":2,"method":"tools/list"}')
36
+ if tool_names:
37
+ example_tool = tool_names[0]
38
+ example_schema = tools[0].get("inputSchema", {})
39
+ example_args = dict.fromkeys(example_schema.get("properties", {}), "...")
40
+ args_json = json.dumps(example_args) if example_args else "{}"
41
+ _stderr(
42
+ f' {{"jsonrpc":"2.0","id":3,"method":"tools/call",'
43
+ f'"params":{{"name":"{example_tool}","arguments":{args_json}}}}}'
44
+ )
45
+ _stderr("")
46
+ _stderr("Or pipe from a file:")
47
+ _stderr(f" cat requests.jsonl | {cli.name} --mcp")
48
+ _stderr("")
49
+ _stderr("Press Ctrl+C to stop.")
50
+ _stderr("")
51
+
52
+ for line in sys.stdin:
53
+ line = line.strip()
54
+ if not line:
55
+ continue
56
+ try:
57
+ request = json.loads(line)
58
+ except json.JSONDecodeError:
59
+ _write_error(None, -32700, "Parse error")
60
+ continue
61
+
62
+ req_id = request.get("id")
63
+ method = request.get("method", "")
64
+
65
+ try:
66
+ result = _handle_method(cli, method, request.get("params", {}))
67
+ if result is not None:
68
+ _write_result(req_id, result)
69
+ except Exception as e:
70
+ _write_error(req_id, -32603, str(e))
71
+
72
+
73
+ def _handle_method(cli: CLI, method: str, params: dict[str, Any]) -> dict[str, Any] | None:
74
+ """Dispatch an MCP method."""
75
+ match method:
76
+ case "initialize":
77
+ return {
78
+ "protocolVersion": _MCP_VERSION,
79
+ "capabilities": {"tools": {}, "resources": {}, "prompts": {}},
80
+ "serverInfo": {
81
+ "name": cli.name or _SERVER_NAME,
82
+ "version": cli.version or _SERVER_VERSION,
83
+ "title": cli.description,
84
+ },
85
+ "instructions": cli.description,
86
+ }
87
+ case "notifications/initialized":
88
+ # Client confirms initialization — no response required
89
+ return None
90
+ case "tools/list":
91
+ return {"tools": _list_tools(cli)}
92
+ case "tools/call":
93
+ return _call_tool(cli, params)
94
+ case "resources/list":
95
+ return {"resources": _list_resources(cli)}
96
+ case "resources/read":
97
+ return _read_resource(cli, params)
98
+ case "prompts/list":
99
+ return {"prompts": _list_prompts(cli)}
100
+ case "prompts/get":
101
+ return _get_prompt(cli, params)
102
+ case _:
103
+ raise ValueError(f"Unknown method: {method!r}")
104
+
105
+
106
+ def _list_tools(cli: CLI) -> list[dict[str, Any]]:
107
+ """Generate MCP tools/list response from all commands including groups.
108
+
109
+ Group commands use dot-notation names: ``site.build``, ``site.config.show``.
110
+ Includes outputSchema when return type annotations are available.
111
+ """
112
+ tools = []
113
+ for dotted_name, cmd in cli.walk_commands():
114
+ if cmd.hidden:
115
+ continue
116
+ tool: dict[str, Any] = {
117
+ "name": dotted_name,
118
+ "description": cmd.description,
119
+ "inputSchema": cmd.schema,
120
+ }
121
+
122
+ # title: human-readable display name from docstring or description
123
+ title = _tool_title(cmd)
124
+ if title:
125
+ tool["title"] = title
126
+
127
+ # outputSchema: generated from handler return type annotation
128
+ output_schema = _output_schema(cmd)
129
+ if output_schema:
130
+ tool["outputSchema"] = output_schema
131
+
132
+ tools.append(tool)
133
+ return tools
134
+
135
+
136
+ def _tool_title(cmd: CommandDef | LazyCommandDef) -> str:
137
+ """Derive a human-readable title for a tool.
138
+
139
+ Uses the handler's docstring first line if available and different
140
+ from the description. Falls back to a title-cased command name.
141
+ """
142
+ from milo.commands import LazyCommandDef
143
+
144
+ # For lazy commands, use name-derived title to avoid triggering imports
145
+ if isinstance(cmd, LazyCommandDef) and cmd._resolved is None:
146
+ return cmd.name.replace("-", " ").replace("_", " ").title()
147
+
148
+ doc = getattr(cmd.handler, "__doc__", None)
149
+ if doc:
150
+ first_line = doc.strip().split("\n")[0].strip().rstrip(".")
151
+ if first_line and first_line != cmd.description:
152
+ return first_line
153
+
154
+ return cmd.name.replace("-", " ").replace("_", " ").title()
155
+
156
+
157
+ def _output_schema(cmd: CommandDef | LazyCommandDef) -> dict[str, Any] | None:
158
+ """Generate outputSchema from handler return type annotation.
159
+
160
+ Returns None for lazy commands that haven't been resolved (avoids imports).
161
+ """
162
+ from milo.commands import LazyCommandDef
163
+ from milo.schema import return_to_schema
164
+
165
+ # Don't trigger imports on lazy commands just for output schema
166
+ if isinstance(cmd, LazyCommandDef) and cmd._resolved is None:
167
+ return None
168
+
169
+ return return_to_schema(cmd.handler)
170
+
171
+
172
+ def _call_tool(cli: CLI, params: dict[str, Any]) -> dict[str, Any]:
173
+ """Handle tools/call by dispatching to the command handler."""
174
+ tool_name = params.get("name", "")
175
+ arguments = params.get("arguments", {})
176
+
177
+ try:
178
+ result = cli.call(tool_name, **arguments)
179
+ except Exception as e:
180
+ return {
181
+ "content": [{"type": "text", "text": f"Error: {e}"}],
182
+ "isError": True,
183
+ }
184
+
185
+ # Convert result to MCP content with structuredContent for non-string results
186
+ text = result if isinstance(result, str) else json.dumps(result, indent=2, default=str)
187
+
188
+ response: dict[str, Any] = {
189
+ "content": [{"type": "text", "text": text}],
190
+ }
191
+
192
+ # Include structuredContent for structured data (dict, list, number, bool)
193
+ if not isinstance(result, str) and result is not None:
194
+ response["structuredContent"] = result
195
+
196
+ return response
197
+
198
+
199
+ def _list_resources(cli: CLI) -> list[dict[str, Any]]:
200
+ """Generate MCP resources/list response from registered resources."""
201
+ resources = []
202
+ for _uri, res in cli.walk_resources():
203
+ resources.append(
204
+ {
205
+ "uri": res.uri,
206
+ "name": res.name,
207
+ "description": res.description,
208
+ "mimeType": res.mime_type,
209
+ }
210
+ )
211
+ return resources
212
+
213
+
214
+ def _read_resource(cli: CLI, params: dict[str, Any]) -> dict[str, Any]:
215
+ """Handle resources/read by calling the resource handler."""
216
+ uri = params.get("uri", "")
217
+
218
+ res = cli._resources.get(uri)
219
+ if not res:
220
+ return {"contents": []}
221
+
222
+ try:
223
+ result = res.handler()
224
+ except Exception as e:
225
+ return {"contents": [{"uri": uri, "text": f"Error: {e}", "mimeType": "text/plain"}]}
226
+
227
+ text = result if isinstance(result, str) else json.dumps(result, indent=2, default=str)
228
+
229
+ return {"contents": [{"uri": uri, "text": text, "mimeType": res.mime_type}]}
230
+
231
+
232
+ def _list_prompts(cli: CLI) -> list[dict[str, Any]]:
233
+ """Generate MCP prompts/list response from registered prompts."""
234
+ prompts = []
235
+ for _name, p in cli.walk_prompts():
236
+ prompt_info: dict[str, Any] = {
237
+ "name": p.name,
238
+ "description": p.description,
239
+ }
240
+ if p.arguments:
241
+ prompt_info["arguments"] = list(p.arguments)
242
+ prompts.append(prompt_info)
243
+ return prompts
244
+
245
+
246
+ def _get_prompt(cli: CLI, params: dict[str, Any]) -> dict[str, Any]:
247
+ """Handle prompts/get by calling the prompt handler."""
248
+ name = params.get("name", "")
249
+ arguments = params.get("arguments", {})
250
+
251
+ p = cli._prompts.get(name)
252
+ if not p:
253
+ return {"messages": []}
254
+
255
+ try:
256
+ result = p.handler(**arguments)
257
+ except Exception as e:
258
+ return {"messages": [{"role": "user", "content": {"type": "text", "text": f"Error: {e}"}}]}
259
+
260
+ # If handler returns list of dicts, treat as messages
261
+ if isinstance(result, list):
262
+ return {"messages": result}
263
+
264
+ # If handler returns a string, wrap as single user message
265
+ if isinstance(result, str):
266
+ return {"messages": [{"role": "user", "content": {"type": "text", "text": result}}]}
267
+
268
+ return {
269
+ "messages": [
270
+ {
271
+ "role": "user",
272
+ "content": {"type": "text", "text": json.dumps(result, default=str)},
273
+ }
274
+ ]
275
+ }
276
+
277
+
278
+ def _write_result(req_id: Any, result: dict[str, Any]) -> None:
279
+ """Write a JSON-RPC success response."""
280
+ response = {"jsonrpc": "2.0", "id": req_id, "result": result}
281
+ sys.stdout.write(json.dumps(response) + "\n")
282
+ sys.stdout.flush()
283
+
284
+
285
+ def _write_error(req_id: Any, code: int, message: str) -> None:
286
+ """Write a JSON-RPC error response."""
287
+ response = {
288
+ "jsonrpc": "2.0",
289
+ "id": req_id,
290
+ "error": {"code": code, "message": message},
291
+ }
292
+ sys.stdout.write(json.dumps(response) + "\n")
293
+ sys.stdout.flush()
294
+
295
+
296
+ def _stderr(message: str) -> None:
297
+ """Write an informational line to stderr."""
298
+ sys.stderr.write(message + "\n")
299
+ sys.stderr.flush()
milo/middleware.py ADDED
@@ -0,0 +1,67 @@
1
+ """Middleware stack for MCP and CLI call interception."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+ from dataclasses import dataclass, field
7
+ from typing import Any
8
+
9
+ # Type aliases
10
+ MiddlewareFn = Callable[..., Any]
11
+ NextFn = Callable[["MCPCall"], Any]
12
+
13
+
14
+ @dataclass(frozen=True, slots=True)
15
+ class MCPCall:
16
+ """Represents an interceptable MCP or CLI call."""
17
+
18
+ method: str # "tools/call", "resources/read", etc.
19
+ name: str # tool/resource/prompt name
20
+ arguments: dict[str, Any] = field(default_factory=dict)
21
+ metadata: dict[str, Any] = field(default_factory=dict)
22
+
23
+
24
+ class MiddlewareStack:
25
+ """Ordered middleware pipeline for intercepting calls.
26
+
27
+ Middleware signature: ``def mw(ctx, call: MCPCall, next_fn: NextFn) -> Any``
28
+
29
+ Usage::
30
+
31
+ stack = MiddlewareStack()
32
+
33
+ @stack.use
34
+ def log_calls(ctx, call, next_fn):
35
+ print(f"Calling {call.name}")
36
+ result = next_fn(call)
37
+ print(f"Done {call.name}")
38
+ return result
39
+
40
+ result = stack.execute(ctx, call, handler)
41
+ """
42
+
43
+ def __init__(self) -> None:
44
+ self._middlewares: list[MiddlewareFn] = []
45
+
46
+ def use(self, fn: MiddlewareFn) -> MiddlewareFn:
47
+ """Register a middleware function. Can be used as a decorator."""
48
+ self._middlewares.append(fn)
49
+ return fn
50
+
51
+ def execute(self, ctx: Any, call: MCPCall, handler: Callable[..., Any]) -> Any:
52
+ """Execute the middleware chain, ending with the handler."""
53
+ if not self._middlewares:
54
+ return handler(call)
55
+
56
+ def build_next(index: int) -> NextFn:
57
+ if index >= len(self._middlewares):
58
+ return handler
59
+ mw = self._middlewares[index]
60
+
61
+ def next_fn(c: MCPCall) -> Any:
62
+ return mw(ctx, c, build_next(index + 1))
63
+
64
+ return next_fn
65
+
66
+ first = self._middlewares[0]
67
+ return first(ctx, call, build_next(1))
milo/observability.py ADDED
@@ -0,0 +1,111 @@
1
+ """Observability for MCP requests — logging, stats, correlation IDs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextvars
6
+ import threading
7
+ import time
8
+ import uuid
9
+ from collections import deque
10
+ from dataclasses import dataclass
11
+ from typing import Any
12
+
13
+ # Correlation ID for request tracing
14
+ correlation_id: contextvars.ContextVar[str] = contextvars.ContextVar(
15
+ "milo_correlation_id", default=""
16
+ )
17
+
18
+
19
+ def new_correlation_id() -> str:
20
+ """Generate and set a new correlation ID. Returns the ID."""
21
+ cid = uuid.uuid4().hex[:12]
22
+ correlation_id.set(cid)
23
+ return cid
24
+
25
+
26
+ @dataclass(frozen=True, slots=True)
27
+ class RequestLog:
28
+ """A single MCP request log entry."""
29
+
30
+ correlation_id: str
31
+ method: str
32
+ name: str
33
+ latency_ms: float
34
+ error: str = ""
35
+ cli_name: str = ""
36
+ timestamp: float = 0.0
37
+
38
+
39
+ class RequestLogger:
40
+ """Thread-safe ring buffer for MCP request logs with stats computation."""
41
+
42
+ def __init__(self, max_size: int = 1000) -> None:
43
+ self._buffer: deque[RequestLog] = deque(maxlen=max_size)
44
+ self._lock = threading.Lock()
45
+ self._total: int = 0
46
+ self._errors: int = 0
47
+
48
+ def record(self, log: RequestLog) -> None:
49
+ """Record a request log entry."""
50
+ with self._lock:
51
+ self._buffer.append(log)
52
+ self._total += 1
53
+ if log.error:
54
+ self._errors += 1
55
+
56
+ def recent(self, n: int = 20) -> list[RequestLog]:
57
+ """Return the N most recent log entries."""
58
+ with self._lock:
59
+ items = list(self._buffer)
60
+ return items[-n:]
61
+
62
+ def stats(self) -> dict[str, Any]:
63
+ """Compute aggregate statistics."""
64
+ with self._lock:
65
+ items = list(self._buffer)
66
+ total = self._total
67
+ errors = self._errors
68
+
69
+ if not items:
70
+ return {
71
+ "total": total,
72
+ "errors": errors,
73
+ "avg_latency_ms": 0.0,
74
+ "p99_latency_ms": 0.0,
75
+ }
76
+
77
+ latencies = sorted(log.latency_ms for log in items)
78
+ avg = sum(latencies) / len(latencies)
79
+ p99_idx = max(0, int(len(latencies) * 0.99) - 1)
80
+
81
+ return {
82
+ "total": total,
83
+ "errors": errors,
84
+ "avg_latency_ms": round(avg, 2),
85
+ "p99_latency_ms": round(latencies[p99_idx], 2),
86
+ }
87
+
88
+
89
+ def log_request(
90
+ logger: RequestLogger,
91
+ method: str,
92
+ name: str,
93
+ start_time: float,
94
+ *,
95
+ error: str = "",
96
+ cli_name: str = "",
97
+ ) -> RequestLog:
98
+ """Create and record a request log entry. Returns the log."""
99
+ elapsed = (time.monotonic() - start_time) * 1000
100
+ cid = correlation_id.get("")
101
+ entry = RequestLog(
102
+ correlation_id=cid,
103
+ method=method,
104
+ name=name,
105
+ latency_ms=round(elapsed, 2),
106
+ error=error,
107
+ cli_name=cli_name,
108
+ timestamp=time.time(),
109
+ )
110
+ logger.record(entry)
111
+ return entry
milo/output.py ADDED
@@ -0,0 +1,106 @@
1
+ """Structured output formatting for CLI commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sys
7
+ from typing import Any
8
+
9
+
10
+ def format_output(data: Any, fmt: str = "plain", template: str = "") -> str:
11
+ """Format command output based on requested format.
12
+
13
+ Formats:
14
+ plain — human-readable (default)
15
+ json — JSON output
16
+ table — tabular output (for lists of dicts)
17
+ template — render through a kida template
18
+ """
19
+ match fmt:
20
+ case "json":
21
+ return _format_json(data)
22
+ case "table":
23
+ return _format_table(data)
24
+ case "template":
25
+ return _format_template(data, template)
26
+ case _:
27
+ return _format_plain(data)
28
+
29
+
30
+ def _format_json(data: Any) -> str:
31
+ """JSON output."""
32
+ return json.dumps(data, indent=2, default=str)
33
+
34
+
35
+ def _format_plain(data: Any) -> str:
36
+ """Human-readable plain output."""
37
+ if isinstance(data, list):
38
+ lines = []
39
+ for item in data:
40
+ if isinstance(item, dict):
41
+ parts = [f"{v}" for v in item.values()]
42
+ lines.append(" ".join(parts))
43
+ else:
44
+ lines.append(str(item))
45
+ return "\n".join(lines)
46
+ if isinstance(data, dict):
47
+ lines = []
48
+ max_key = max((len(str(k)) for k in data), default=0)
49
+ for k, v in data.items():
50
+ lines.append(f" {k!s:<{max_key}} {v}")
51
+ return "\n".join(lines)
52
+ return str(data)
53
+
54
+
55
+ def _format_table(data: Any) -> str:
56
+ """Table output using kida's table filter if available."""
57
+ if not isinstance(data, list):
58
+ return _format_plain(data)
59
+
60
+ if not data:
61
+ return "(empty)"
62
+
63
+ # Extract headers from first dict item
64
+ if isinstance(data[0], dict):
65
+ headers = list(data[0].keys())
66
+ rows = [[str(item.get(h, "")) for h in headers] for item in data]
67
+ else:
68
+ headers = None
69
+ rows = [[str(item)] for item in data]
70
+
71
+ try:
72
+ from kida import Environment
73
+
74
+ env = Environment(autoescape="terminal")
75
+ tmpl = env.from_string("{{ data | table(headers=headers) }}", name="table_fmt")
76
+ return tmpl.render(data=rows, headers=headers or [])
77
+ except ImportError:
78
+ # Fallback: simple column alignment
79
+ all_rows = [headers, *rows] if headers else rows
80
+ widths = [max(len(str(cell)) for cell in col) for col in zip(*all_rows, strict=False)]
81
+ lines = []
82
+ for row in all_rows:
83
+ lines.append(
84
+ " ".join(str(cell).ljust(w) for cell, w in zip(row, widths, strict=False))
85
+ )
86
+ if row is headers:
87
+ lines.append(" ".join("-" * w for w in widths))
88
+ return "\n".join(lines)
89
+
90
+
91
+ def _format_template(data: Any, template_name: str) -> str:
92
+ """Render through a kida template."""
93
+ if not template_name:
94
+ return _format_plain(data)
95
+ from milo.templates import get_env
96
+
97
+ env = get_env()
98
+ tmpl = env.get_template(template_name)
99
+ return tmpl.render(state=data)
100
+
101
+
102
+ def write_output(data: Any, fmt: str = "plain", template: str = "") -> None:
103
+ """Format and write command output to stdout."""
104
+ output = format_output(data, fmt=fmt, template=template)
105
+ sys.stdout.write(output + "\n")
106
+ sys.stdout.flush()