oct
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.MCPServer` instance (MCP Python SDK v2),
10binds it to a validated project root (Layer 1 - Gateway), and exposes
11the twenty Phase 5A/5B tools via :mod:`oct.mcp.tools`.
12
13Responsibilities
14----------------
15- Implement :func:`build_mcp_server`: construct the SDK server object
16 and register all twenty tools, without running any transport.
17- Implement :func:`start_server`: resolve + validate project root,
18 load config, instantiate all pipeline layers, register tools, run.
19- Implement the token-bucket rate limiter (Layer 1) via
20 :class:`RateLimiter`.
21- Expose ``_server_state`` for tools to access the shared pipeline
22 instances (singleton pattern: one server = one context).
23
24Diagnostics
25-----------
26Domain: MCP
27Levels:
28 L1 - errors: startup failures, workspace binding failures
29 L2 - lifecycle: server start/stop, workspace bound
30 L3 - request details
31 L4 - deep trace
32
33Contracts
34---------
35- :func:`start_server` raises ``SystemExit(1)`` on fatal startup errors
36 (invalid project root, no ``oct`` command, standard output that
37 cannot be handed back to the stdio transport) so ``__main__.py`` can
38 propagate a clean exit code.
39- The SDK is MCP Python SDK v2 (``mcp>=2.2,<3``) with no v1 fallback:
40 the two lines differ in threading and error semantics, and a security
41 layer must not straddle them (plan 244, P1).
42- Under v2 a synchronous tool handler runs on a worker thread
43 (``anyio.to_thread.run_sync``) and concurrent requests overlap, so
44 every tool call is serialised by the process-wide lock held in
45 :mod:`oct.mcp.tools`. The rate limiter, executor, audit logger and
46 metrics behind ``_server_state`` therefore keep the single-threaded
47 contract they were written for (plan 244, P3).
48- Under the ``stdio`` transport, standard output (fd 1) is the JSON-RPC
49 channel. :func:`oct.mcp.__main__.main` diverts it to standard error
50 for the startup window, and :func:`start_server` hands it back with
51 :func:`oct.mcp.stdout_diversion.restore_stdout` immediately before
52 ``MCPServer.run(transport="stdio")``, whose ``stdio_server()`` then
53 diverts fd 1 again for the serving window. When the hand-back fails,
54 :func:`start_server` exits with code 1 instead of calling ``run``,
55 because the SDK would take the still-diverted fd 1 as its wire and
56 serve the protocol on standard error. Nothing is written after
57 ``run`` returns; ``_dbg`` flushes every record, so only an unflushed
58 ``print()`` made while serving, or a record that a broken standard
59 error rejected while serving, could still reach the wire when the
60 SDK hands fd 1 back at shutdown.
61- The rate limiter is per-server-instance (not per-session), which is
62 sufficient for the stdio transport where one server process handles
63 one client session.
64- ``_server_state`` is set before :meth:`MCPServer.run` is called and is
65 available to all tool handlers during their execution.
66
67Dependencies
68------------
69- oct.core.diagnostics (_dbg structured logger)
70- oct.mcp.audit (McpAuditLogger, the Layer 6 audit log)
71- oct.mcp.config (McpConfig, load_config and VALID_MCP_PROFILES for the server settings)
72- oct.mcp.executor (ToolExecutor, which maps tool arguments to oct CLI calls)
73- oct.mcp.metrics (McpMetrics, the optional Prometheus counters and exporter)
74- oct.mcp.policy (McpPolicy, the Layer 3 profile rules)
75- oct.mcp.redactor (Redactor, the Layer 5 output sanitiser)
76- oct.mcp.safety (SafetyGate, the Layer 2.5 consent and dry-run gate)
77- oct.mcp.sandbox (SandboxExecutor and make_sandbox_backend, the Layer 4 sandbox)
78- oct.mcp.stdout_diversion (restore_stdout and stdout_is_diverted, which hand fd 1 back to the stdio transport and confirm it)
79- oct.core.project_root (lazy; find_project_root when no project root is given)
80- oct.mcp.policy_loader (lazy; the centralised policy overrides when a policy source is set)
81- oct.mcp.tools (lazy; registers the twenty tools on the SDK server)
82- mcp (MCPServer from the MCP Python SDK v2, and its transports)
83"""
84
85from __future__ import annotations
86
87import os
88import sys
89import time
90from dataclasses import dataclass, field
91from pathlib import Path
92from typing import Any
93
94from mcp.server import MCPServer
95
96from oct.core.diagnostics import _dbg
97
98from oct.mcp.audit import McpAuditLogger
99from oct.mcp.config import McpConfig, load_config
100from oct.mcp.executor import ToolExecutor
101from oct.mcp.metrics import McpMetrics
102from oct.mcp.policy import McpPolicy
103from oct.mcp.redactor import Redactor
104from oct.mcp.safety import SafetyGate
105from oct.mcp.sandbox import SandboxExecutor, make_sandbox_backend
106from oct.mcp.stdout_diversion import restore_stdout, stdout_is_diverted
107
108
109# ---------------------------------------------------------------------
110# Rate limiter (Layer 1, token-bucket)
111# ---------------------------------------------------------------------
112
113
115 """Simple token-bucket rate limiter (stdlib only).
116
117 One token is consumed per tool call. Tokens refill at the rate of
118 ``limit_per_minute / 60`` tokens per second. The bucket starts full.
119
120 Thread safety: this class is NOT internally synchronised. Under MCP
121 SDK v2 a synchronous tool handler runs on a worker thread, so the
122 bucket is protected instead by the process-wide tool-call lock in
123 :mod:`oct.mcp.tools`, which serialises every call (plan 244, P3).
124 """
125
126 def __init__(self, limit_per_minute: int = 30) -> None:
127 self._limit = max(1, limit_per_minute)
128 self._tokens: float = float(self._limit)
129 self._last_refill: float = time.monotonic()
130
131 def _refill(self) -> None:
132 now = time.monotonic()
133 elapsed = now - self._last_refill
134 self._tokens = min(
135 float(self._limit),
136 self._tokens + elapsed * (self._limit / 60.0),
137 )
138 self._last_refill = now
139
140 def consume(self) -> bool:
141 """Try to consume one token. Returns ``False`` if rate limit is exceeded."""
142 self._refill()
143 if self._tokens >= 1.0:
144 self._tokens -= 1.0
145 return True
146 return False
147
148
149# ---------------------------------------------------------------------
150# Server state (shared pipeline instances)
151# ---------------------------------------------------------------------
152
153
154@dataclass
156 """All pipeline layer instances for one server session.
157
158 Created in :func:`start_server` and stored in the module-level
159 ``_server_state`` singleton so tool handlers can access it without
160 having to pass context through the MCP SDK call graph.
161
162 Thread safety: none of these instances is internally synchronised.
163 MCP SDK v2 dispatches synchronous tool handlers to worker threads
164 and lets requests overlap, so tool calls are serialised by the
165 process-wide lock in :mod:`oct.mcp.tools`; only one call is ever
166 inside the pipeline at a time (plan 244, P3).
167 """
168
169 project_root: Path
170 config: McpConfig
171 policy: McpPolicy
172 executor: ToolExecutor
173 redactor: Redactor
174 audit_logger: McpAuditLogger
175 rate_limiter: RateLimiter
176 safety: SafetyGate
177 metrics: McpMetrics
178 session_id: str = ""
179
180
181#: Module-level singleton. Set by :func:`start_server` before
182#: :meth:`MCPServer.run` is called. ``None`` outside server context.
183_server_state: ServerState | None = None
184
185
186def get_server_state() -> ServerState:
187 """Return the current :class:`ServerState`.
188
189 Raises ``RuntimeError`` if called outside a running server context
190 (i.e. before :func:`start_server` has initialised the state).
191 """
192 if _server_state is None:
193 raise RuntimeError(
194 "oct-mcp server state is not initialised. "
195 "Call start_server() before accessing server state."
196 )
197 return _server_state
198
199
200# ---------------------------------------------------------------------
201# Workspace binding (Layer 1 - Gateway)
202# ---------------------------------------------------------------------
203
204
205def _resolve_project_root(root_arg: str | Path | None) -> Path:
206 """Resolve and validate the project root directory.
207
208 Parameters
209 ----------
210 root_arg
211 Path provided via ``--project-root`` CLI argument, or ``None``
212 to auto-discover from the current working directory.
213
214 Returns
215 -------
216 Resolved absolute :class:`Path`.
217
218 Raises
219 ------
220 SystemExit(1)
221 If the directory does not exist or is not a directory.
222 """
223 func = "_resolve_project_root"
224 if root_arg:
225 candidate = Path(root_arg).resolve()
226 else:
227 try:
228 from oct.core.project_root import find_project_root
229 candidate = find_project_root(Path.cwd())
230 except Exception:
231 candidate = Path.cwd().resolve()
232
233 if not candidate.exists():
234 _dbg("MCP", func, f"SYSTEM_ERROR: project root not found: {candidate}", 1)
235 print(f"oct-mcp: error: project root does not exist: {candidate}", file=sys.stderr)
236 sys.exit(1)
237
238 if not candidate.is_dir():
239 _dbg("MCP", func, f"SYSTEM_ERROR: project root is not a directory: {candidate}", 1)
240 print(
241 f"oct-mcp: error: project root is not a directory: {candidate}",
242 file=sys.stderr,
243 )
244 sys.exit(1)
245
246 _dbg("MCP", func, f"project_root={candidate}", 2)
247 return candidate
248
249
250# ---------------------------------------------------------------------
251# SDK server construction
252# ---------------------------------------------------------------------
253
254#: Instructions advertised to the AI host during MCP initialisation.
255SERVER_INSTRUCTIONS: str = (
256 "Option C Tools MCP server - exposes oct linter, health check, "
257 "skeleton exporter, dependency checker, test runner, type checker, "
258 "diagnostics, and git tools as MCP tools."
259)
260
261
262def _oct_version() -> str:
263 """Return the installed ``oct`` distribution version.
264
265 The MCP server advertises this to the host as its ``version``. It is
266 read from distribution metadata rather than written as a literal so
267 the wire value can never drift from the installed package.
268
269 Returns
270 -------
271 The ``oct`` version string, or ``"0.0.0"`` when ``oct`` is not
272 installed as a distribution (a source checkout on ``sys.path``).
273 """
274 func = "_oct_version"
275 try:
276 from importlib.metadata import version as _dist_version
277 return _dist_version("oct")
278 except Exception as exc: # pragma: no cover - source-checkout fallback
279 _dbg("MCP", func, f"oct distribution metadata unavailable: {exc}", 1)
280 return "0.0.0"
281
282
283def build_mcp_server() -> MCPServer:
284 """Create the SDK server object and register all twenty tools.
285
286 Factored out of :func:`start_server` so the construction path can be
287 exercised - and the advertised tool list inspected - without binding
288 a transport (plan 244, W1).
289
290 Returns
291 -------
292 An :class:`mcp.server.MCPServer` with the nine Phase 5A read-only
293 tools and the eleven Phase 5B write tools registered.
294 """
295 func = "build_mcp_server"
296 mcp_server = MCPServer(
297 name="oct-mcp",
298 instructions=SERVER_INSTRUCTIONS,
299 version=_oct_version(),
300 )
301
302 from oct.mcp import tools as _tools_module
303 _tools_module.register_tools(mcp_server)
304 _tools_module.register_write_tools(mcp_server)
305
306 _dbg("MCP", func, f"registered tools version={mcp_server.version}", 2)
307 return mcp_server
308
309
310# ---------------------------------------------------------------------
311# Server entry point
312# ---------------------------------------------------------------------
313
314
316 project_root: str | Path | None = None,
317 profile: str | None = None,
318 config_path: Path | None = None,
319 transport: str = "stdio",
320 host: str = "127.0.0.1",
321 port: int = 3001,
322 metrics_port: int | None = None,
323) -> None:
324 """Initialise and run the ``oct-mcp`` server.
325
326 Steps:
327
328 1. Resolve and validate project root (Layer 1 - Gateway).
329 2. Load :class:`McpConfig` (with env var overrides).
330 3. Apply centralised policy overrides (Phase 5C, M-C7).
331 4. Override profile if ``--profile`` CLI arg was provided.
332 5. Instantiate all pipeline layers.
333 6. Start Prometheus metrics server (optional, Phase 5C).
334 7. Set ``_server_state`` singleton.
335 8. Build the SDK server with all 20 tools registered
336 (:func:`build_mcp_server`).
337 9. Run the server (stdio, streamable HTTP, or SSE transport). Under
338 stdio, first hand fd 1 back to the transport
339 (:func:`oct.mcp.stdout_diversion.restore_stdout`; a no-op when no
340 diversion is active, as in an in-process call), and exit with
341 code 1 if a diversion is still recorded afterwards.
342
343 Parameters
344 ----------
345 project_root
346 Project root path override (``None`` = auto-discover).
347 profile
348 MCP profile override (``None`` = use config file / env var).
349 config_path
350 Path to the ``~/.oct-mcp/config.json`` config file
351 (``None`` = use default location).
352 transport
353 MCP transport: ``"stdio"`` (default) for local AI hosts,
354 ``"streamable-http"`` for remote access, or ``"sse"`` for the
355 legacy HTTP/Server-Sent Events transport.
356 host
357 Host to bind for the HTTP transports (default ``"127.0.0.1"``).
358 Ignored when *transport* is ``"stdio"``.
359 port
360 Port to bind for the HTTP transports (default ``3001``).
361 Ignored when *transport* is ``"stdio"``.
362 metrics_port
363 If set, starts a Prometheus ``/metrics`` server on this port.
364 Overrides ``config.metrics_port``. Requires ``oct[metrics]``.
365 """
366 global _server_state
367 func = "start_server"
368
369 # Step 1: workspace binding
370 root = _resolve_project_root(project_root)
371
372 # Step 2: load config
373 config = load_config(config_path)
374
375 # Step 3: apply centralised policy overrides (Phase 5C)
376 if config.policy_source:
377 from oct.mcp.policy_loader import apply_policy_overrides, load_policy_source
378 overrides = load_policy_source(config.policy_source)
379 if overrides:
380 config = apply_policy_overrides(config, overrides) # type: ignore[assignment]
381
382 # Step 4: profile override from CLI
383 from oct.mcp.config import VALID_MCP_PROFILES
384 if profile and profile in VALID_MCP_PROFILES:
385 config.profile = profile
386 _dbg("MCP", func, f"profile overridden to {profile} via CLI", 2)
387
388 _dbg(
389 "MCP", func,
390 f"starting server profile={config.profile} transport={transport} root={root}",
391 2,
392 )
393
394 # Step 5: instantiate pipeline layers
395 import uuid
396 session_id = str(uuid.uuid4())
397
398 policy = McpPolicy(profile=config.profile, safe_mode=config.safe_mode)
399 backend = make_sandbox_backend(config)
400 sandbox = SandboxExecutor(
401 backend=backend,
402 memory_limit_mb=config.memory_limit_mb,
403 )
404 executor = ToolExecutor(
405 sandbox=sandbox,
406 timeout_default=config.timeout_default_seconds,
407 timeout_test=config.timeout_test_seconds,
408 max_output_bytes=config.max_output_bytes,
409 )
410 redactor = Redactor(
411 redaction_patterns=_load_debug_config_patterns(root, config),
412 max_output_bytes=config.max_output_bytes,
413 )
414 audit_logger = McpAuditLogger(
415 audit_log_path=config.audit_log_path,
416 max_files=config.audit_log_max_files,
417 max_size_bytes=int(config.audit_log_max_size_mb * 1024 * 1024),
418 )
419 rate_limiter = RateLimiter(limit_per_minute=config.rate_limit_per_minute)
420 safety = SafetyGate(config)
421 metrics = McpMetrics()
422
423 # Step 6: start Prometheus metrics server if enabled (Phase 5C)
424 effective_metrics_port = metrics_port if metrics_port is not None else config.metrics_port
425 if effective_metrics_port is not None:
426 metrics.start_server(effective_metrics_port)
427 _dbg("MCP", func, f"Prometheus metrics on port {effective_metrics_port}", 2)
428
429 # Step 7: set server state singleton before registering tools
430 _server_state = ServerState(
431 project_root=root,
432 config=config,
433 policy=policy,
434 executor=executor,
435 redactor=redactor,
436 audit_logger=audit_logger,
437 rate_limiter=rate_limiter,
438 safety=safety,
439 metrics=metrics,
440 session_id=session_id,
441 )
442
443 # Step 8: build the SDK server with all 20 tools registered
444 mcp_server = build_mcp_server()
445
446 _dbg("MCP", func, f"server ready session_id={session_id}", 2)
447
448 # Step 9: run (blocks until client disconnects)
449 if transport == "streamable-http":
450 mcp_server.run(transport="streamable-http", host=host, port=port)
451 elif transport == "sse":
452 mcp_server.run(transport="sse", host=host, port=port)
453 else:
454 # Under main(), every startup write above went to stderr. Hand fd 1
455 # back to the wire; the SDK's stdio_server() diverts it again as soon
456 # as run() enters the transport.
457 restore_stdout()
458 if stdout_is_diverted():
459 # The hand-back failed, so fd 1 is still off the wire. The SDK
460 # would take it as the wire and serve the protocol on stderr
461 # (or nowhere), where the client never sees it; stop instead.
462 _dbg(
463 "MCP", func,
464 "SYSTEM_ERROR: standard output is still diverted; "
465 "the stdio transport was not started",
466 1,
467 )
468 print(
469 "oct-mcp: error: could not hand standard output back to the "
470 "stdio transport",
471 file=sys.stderr,
472 )
473 sys.exit(1)
474 mcp_server.run(transport="stdio")
475
476
477def _load_debug_config_patterns(root: Path, config: McpConfig) -> list:
478 """Load ``redaction_patterns`` from ``debug_config.json`` if trusted.
479
480 Only reads the project-level debug config when
481 ``config.trust_repo_config`` is ``True`` and the profile is not
482 ``"airgapped"`` or ``"strict"``.
483
484 Returns an empty list (safe default) on any read/parse failure.
485 """
486 func = "_load_debug_config_patterns"
487 if not config.trust_repo_config:
488 return []
489 if config.profile in ("strict", "airgapped"):
490 return []
491 # OI-504: canonical path since v0.9.0 is oc_diagnostics/debug_config.json.
492 # Fall back to the legacy root-level path for backwards compatibility.
493 canonical = root / "oc_diagnostics" / "debug_config.json"
494 legacy = root / "debug_config.json"
495 if canonical.exists():
496 debug_cfg_path = canonical
497 elif legacy.exists():
498 _dbg(
499 "MCP",
500 func,
501 f"using legacy debug_config.json at {legacy} - migrate to oc_diagnostics/",
502 1,
503 )
504 debug_cfg_path = legacy
505 else:
506 return []
507 try:
508 import json
509 raw = json.loads(debug_cfg_path.read_text(encoding="utf-8"))
510 patterns = raw.get("redaction_patterns", [])
511 if isinstance(patterns, list):
512 _dbg("MCP", func, f"loaded {len(patterns)} redaction patterns", 2)
513 return patterns
514 except Exception as exc:
515 _dbg("MCP", func, f"could not read debug_config.json: {exc}", 1)
516 return []
None __init__(self, int limit_per_minute=30)
Definition server.py:126
MCPServer build_mcp_server()
Definition server.py:283
list _load_debug_config_patterns(Path root, McpConfig config)
Definition server.py:477
ServerState get_server_state()
Definition server.py:186
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:323
Path _resolve_project_root(str|Path|None root_arg)
Definition server.py:205
str _oct_version()
Definition server.py:262