Option C Tools
Loading...
Searching...
No Matches
server.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oct/mcp/server.py
4
5"""
6Purpose
7-------
8MCP server core (Layers 1 + orchestration) for the ``oct-mcp`` server.
9Creates a :class:`mcp.server.fastmcp.FastMCP` instance, binds it to a
10validated project root (Layer 1 — Gateway), and exposes the nine Phase
115A read-only tools via :mod:`oct.mcp.tools`.
12
13Responsibilities
14----------------
15- Implement :func:`start_server`: resolve + validate project root,
16 load config, instantiate all pipeline layers, register tools, run.
17- Implement the token-bucket rate limiter (Layer 1) via
18 :class:`RateLimiter`.
19- Expose ``_server_state`` for tools to access the shared pipeline
20 instances (singleton pattern: one server = one context).
21
22Diagnostics
23-----------
24Domain: MCP
25Levels:
26 L1 — errors: startup failures, workspace binding failures
27 L2 — lifecycle: server start/stop, workspace bound
28 L3 — request details
29 L4 — deep trace
30
31Contracts
32---------
33- :func:`start_server` raises ``SystemExit(1)`` on fatal startup errors
34 (invalid project root, no ``oct`` command) so ``__main__.py`` can
35 propagate a clean exit code.
36- The rate limiter is per-server-instance (not per-session), which is
37 sufficient for the stdio Phase 5A transport where one server process
38 handles one client session.
39- ``_server_state`` is set before :meth:`FastMCP.run` is called and is
40 available to all tool handlers during their execution.
41"""
42
43from __future__ import annotations
44
45import os
46import sys
47import time
48from dataclasses import dataclass, field
49from pathlib import Path
50from typing import Any
51
52try:
53 from mcp.server.fastmcp import FastMCP
54except ImportError as _mcp_err: # pragma: no cover
55 raise ImportError(
56 "mcp>=1.0 is required for oct-mcp. "
57 "Install with: pip install 'oct[mcp]'"
58 ) from _mcp_err
59
60try:
61 from oc_diagnostics import _dbg as _real_dbg
62
63 def _dbg(*args, **kwargs) -> None:
64 _real_dbg(*args, **kwargs)
65except ImportError: # pragma: no cover
66 def _dbg(*args, **kwargs) -> None:
67 return None
68
69from oct.mcp.audit import McpAuditLogger
70from oct.mcp.config import McpConfig, load_config
71from oct.mcp.executor import ToolExecutor
72from oct.mcp.metrics import McpMetrics
73from oct.mcp.policy import McpPolicy
74from oct.mcp.redactor import Redactor
75from oct.mcp.safety import SafetyGate
76from oct.mcp.sandbox import SandboxExecutor, make_sandbox_backend
77
78
79# ---------------------------------------------------------------------
80# Rate limiter (Layer 1, token-bucket)
81# ---------------------------------------------------------------------
82
83
85 """Simple token-bucket rate limiter (stdlib only).
86
87 One token is consumed per tool call. Tokens refill at the rate of
88 ``limit_per_minute / 60`` tokens per second. The bucket starts full.
89
90 Thread safety: not needed for stdio Phase 5A (single-threaded).
91 """
92
93 def __init__(self, limit_per_minute: int = 30) -> None:
94 self._limit = max(1, limit_per_minute)
95 self._tokens: float = float(self._limit)
96 self._last_refill: float = time.monotonic()
97
98 def _refill(self) -> None:
99 now = time.monotonic()
100 elapsed = now - self._last_refill
101 self._tokens = min(
102 float(self._limit),
103 self._tokens + elapsed * (self._limit / 60.0),
104 )
105 self._last_refill = now
106
107 def consume(self) -> bool:
108 """Try to consume one token. Returns ``False`` if rate limit is exceeded."""
109 self._refill()
110 if self._tokens >= 1.0:
111 self._tokens -= 1.0
112 return True
113 return False
114
115
116# ---------------------------------------------------------------------
117# Server state (shared pipeline instances)
118# ---------------------------------------------------------------------
119
120
121@dataclass
123 """All pipeline layer instances for one server session.
124
125 Created in :func:`start_server` and stored in the module-level
126 ``_server_state`` singleton so tool handlers can access it without
127 having to pass context through the MCP SDK call graph.
128 """
129
130 project_root: Path
131 config: McpConfig
132 policy: McpPolicy
133 executor: ToolExecutor
134 redactor: Redactor
135 audit_logger: McpAuditLogger
136 rate_limiter: RateLimiter
137 safety: SafetyGate
138 metrics: McpMetrics
139 session_id: str = ""
140
141
142#: Module-level singleton. Set by :func:`start_server` before
143#: :meth:`FastMCP.run` is called. ``None`` outside server context.
144_server_state: ServerState | None = None
145
146
147def get_server_state() -> ServerState:
148 """Return the current :class:`ServerState`.
149
150 Raises ``RuntimeError`` if called outside a running server context
151 (i.e. before :func:`start_server` has initialised the state).
152 """
153 if _server_state is None:
154 raise RuntimeError(
155 "oct-mcp server state is not initialised. "
156 "Call start_server() before accessing server state."
157 )
158 return _server_state
159
160
161# ---------------------------------------------------------------------
162# Workspace binding (Layer 1 — Gateway)
163# ---------------------------------------------------------------------
164
165
166def _resolve_project_root(root_arg: str | Path | None) -> Path:
167 """Resolve and validate the project root directory.
168
169 Parameters
170 ----------
171 root_arg
172 Path provided via ``--project-root`` CLI argument, or ``None``
173 to auto-discover from the current working directory.
174
175 Returns
176 -------
177 Resolved absolute :class:`Path`.
178
179 Raises
180 ------
181 SystemExit(1)
182 If the directory does not exist or is not a directory.
183 """
184 func = "_resolve_project_root"
185 if root_arg:
186 candidate = Path(root_arg).resolve()
187 else:
188 try:
189 from oct.core.project_root import find_project_root
190 candidate = find_project_root(Path.cwd())
191 except Exception:
192 candidate = Path.cwd().resolve()
193
194 if not candidate.exists():
195 _dbg("MCP", func, f"SYSTEM_ERROR: project root not found: {candidate}", 1)
196 print(f"oct-mcp: error: project root does not exist: {candidate}", file=sys.stderr)
197 sys.exit(1)
198
199 if not candidate.is_dir():
200 _dbg("MCP", func, f"SYSTEM_ERROR: project root is not a directory: {candidate}", 1)
201 print(
202 f"oct-mcp: error: project root is not a directory: {candidate}",
203 file=sys.stderr,
204 )
205 sys.exit(1)
206
207 _dbg("MCP", func, f"project_root={candidate}", 2)
208 return candidate
209
210
211# ---------------------------------------------------------------------
212# Server entry point
213# ---------------------------------------------------------------------
214
215
217 project_root: str | Path | None = None,
218 profile: str | None = None,
219 config_path: Path | None = None,
220 transport: str = "stdio",
221 host: str = "127.0.0.1",
222 port: int = 3001,
223 metrics_port: int | None = None,
224) -> None:
225 """Initialise and run the ``oct-mcp`` server.
226
227 Steps:
228
229 1. Resolve and validate project root (Layer 1 — Gateway).
230 2. Load :class:`McpConfig` (with env var overrides).
231 3. Apply centralised policy overrides (Phase 5C, M-C7).
232 4. Override profile if ``--profile`` CLI arg was provided.
233 5. Instantiate all pipeline layers.
234 6. Start Prometheus metrics server (optional, Phase 5C).
235 7. Create FastMCP server.
236 8. Set ``_server_state`` singleton.
237 9. Register 9 read-only (Phase 5A) + 11 write (Phase 5B) tools.
238 10. Run the FastMCP server (stdio or SSE transport).
239
240 Parameters
241 ----------
242 project_root
243 Project root path override (``None`` = auto-discover).
244 profile
245 MCP profile override (``None`` = use config file / env var).
246 config_path
247 Path to the ``~/.oct-mcp/config.json`` config file
248 (``None`` = use default location).
249 transport
250 MCP transport: ``"stdio"`` (default) or ``"sse"``
251 (HTTP/Server-Sent Events for remote access).
252 host
253 Host to bind for SSE transport (default ``"127.0.0.1"``).
254 Only used when *transport* is ``"sse"``.
255 port
256 Port to bind for SSE transport (default ``3001``).
257 Only used when *transport* is ``"sse"``.
258 metrics_port
259 If set, starts a Prometheus ``/metrics`` server on this port.
260 Overrides ``config.metrics_port``. Requires ``oct[metrics]``.
261 """
262 global _server_state
263 func = "start_server"
264
265 # Step 1: workspace binding
266 root = _resolve_project_root(project_root)
267
268 # Step 2: load config
269 config = load_config(config_path)
270
271 # Step 3: apply centralised policy overrides (Phase 5C)
272 if config.policy_source:
273 from oct.mcp.policy_loader import apply_policy_overrides, load_policy_source
274 overrides = load_policy_source(config.policy_source)
275 if overrides:
276 config = apply_policy_overrides(config, overrides) # type: ignore[assignment]
277
278 # Step 4: profile override from CLI
279 from oct.mcp.config import VALID_MCP_PROFILES
280 if profile and profile in VALID_MCP_PROFILES:
281 config.profile = profile
282 _dbg("MCP", func, f"profile overridden to {profile} via CLI", 2)
283
284 _dbg(
285 "MCP", func,
286 f"starting server profile={config.profile} transport={transport} root={root}",
287 2,
288 )
289
290 # Step 5: instantiate pipeline layers
291 import uuid
292 session_id = str(uuid.uuid4())
293
294 policy = McpPolicy(profile=config.profile, safe_mode=config.safe_mode)
295 backend = make_sandbox_backend(config)
296 sandbox = SandboxExecutor(
297 backend=backend,
298 memory_limit_mb=config.memory_limit_mb,
299 )
300 executor = ToolExecutor(
301 sandbox=sandbox,
302 timeout_default=config.timeout_default_seconds,
303 timeout_test=config.timeout_test_seconds,
304 max_output_bytes=config.max_output_bytes,
305 )
306 redactor = Redactor(
307 redaction_patterns=_load_debug_config_patterns(root, config),
308 max_output_bytes=config.max_output_bytes,
309 )
310 audit_logger = McpAuditLogger(
311 audit_log_path=config.audit_log_path,
312 max_files=config.audit_log_max_files,
313 max_size_bytes=int(config.audit_log_max_size_mb * 1024 * 1024),
314 )
315 rate_limiter = RateLimiter(limit_per_minute=config.rate_limit_per_minute)
316 safety = SafetyGate(config)
317 metrics = McpMetrics()
318
319 # Step 6: start Prometheus metrics server if enabled (Phase 5C)
320 effective_metrics_port = metrics_port if metrics_port is not None else config.metrics_port
321 if effective_metrics_port is not None:
322 metrics.start_server(effective_metrics_port)
323 _dbg("MCP", func, f"Prometheus metrics on port {effective_metrics_port}", 2)
324
325 # Step 7: create FastMCP server and register tools
326 mcp_server = FastMCP(
327 name="oct-mcp",
328 instructions=(
329 "Option C Tools MCP server — exposes oct linter, health check, "
330 "skeleton exporter, dependency checker, test runner, type checker, "
331 "diagnostics, and git tools as MCP tools."
332 ),
333 )
334
335 # Step 8: set server state singleton before registering tools
336 _server_state = ServerState(
337 project_root=root,
338 config=config,
339 policy=policy,
340 executor=executor,
341 redactor=redactor,
342 audit_logger=audit_logger,
343 rate_limiter=rate_limiter,
344 safety=safety,
345 metrics=metrics,
346 session_id=session_id,
347 )
348
349 # Step 9: register tools (read-only Phase 5A + write Phase 5B)
350 from oct.mcp import tools as _tools_module
351 _tools_module.register_tools(mcp_server)
352 _tools_module.register_write_tools(mcp_server)
353
354 _dbg("MCP", func, f"server ready session_id={session_id}", 2)
355
356 # Step 10: run (blocks until client disconnects)
357 if transport == "sse":
358 mcp_server.run(transport="sse", host=host, port=port)
359 else:
360 mcp_server.run(transport="stdio")
361
362
363def _load_debug_config_patterns(root: Path, config: McpConfig) -> list:
364 """Load ``redaction_patterns`` from ``debug_config.json`` if trusted.
365
366 Only reads the project-level debug config when
367 ``config.trust_repo_config`` is ``True`` and the profile is not
368 ``"airgapped"`` or ``"strict"``.
369
370 Returns an empty list (safe default) on any read/parse failure.
371 """
372 func = "_load_debug_config_patterns"
373 if not config.trust_repo_config:
374 return []
375 if config.profile in ("strict", "airgapped"):
376 return []
377 # OI-504: canonical path since v0.9.0 is oc_diagnostics/debug_config.json.
378 # Fall back to the legacy root-level path for backwards compatibility.
379 canonical = root / "oc_diagnostics" / "debug_config.json"
380 legacy = root / "debug_config.json"
381 if canonical.exists():
382 debug_cfg_path = canonical
383 elif legacy.exists():
384 _dbg(
385 "MCP",
386 func,
387 f"using legacy debug_config.json at {legacy} — migrate to oc_diagnostics/",
388 1,
389 )
390 debug_cfg_path = legacy
391 else:
392 return []
393 try:
394 import json
395 raw = json.loads(debug_cfg_path.read_text(encoding="utf-8"))
396 patterns = raw.get("redaction_patterns", [])
397 if isinstance(patterns, list):
398 _dbg("MCP", func, f"loaded {len(patterns)} redaction patterns", 2)
399 return patterns
400 except Exception as exc:
401 _dbg("MCP", func, f"could not read debug_config.json: {exc}", 1)
402 return []
None __init__(self, int limit_per_minute=30)
Definition server.py:93
list _load_debug_config_patterns(Path root, McpConfig config)
Definition server.py:363
ServerState get_server_state()
Definition server.py:147
None start_server(str|Path|None project_root=None, str|None profile=None, Path|None config_path=None, str transport="stdio", str host="127.0.0.1", int port=3001, int|None metrics_port=None)
Definition server.py:224
None _dbg(*args, **kwargs)
Definition server.py:63
Path _resolve_project_root(str|Path|None root_arg)
Definition server.py:166