oct
Loading...
Searching...
No Matches
tools.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oct/mcp/tools.py
4
5"""
6Purpose
7-------
8Phase 5A + 5B tool manifest - 20 MCP tools (9 read-only + 11 write)
9registered with the :class:`mcp.server.MCPServer` instance. Each tool
10function wires the validated input through the six-layer pipeline:
11validator → policy → executor → redactor → audit logger. Write tools
12add a HITL safety gate step between policy and execution.
13
14Responsibilities
15----------------
16- Define :func:`register_tools` which attaches all 9 Phase 5A tools.
17- Define :func:`register_write_tools` which attaches all 11 Phase 5B
18 write tools.
19- Each read tool function follows the six-layer pipeline.
20- Each write tool function follows the same pipeline but calls
21 :func:`_run_tool_write` which inserts the safety gate (Layer 2.5)
22 between policy and execution.
23- Hold ``_TOOL_CALL_LOCK``, the process-wide choke point that keeps the
24 pipeline single-threaded under MCP SDK v2 (plan 244, P3).
25
26Write tool pipeline (HITL gate)
27--------------------------------
28 rate-limiter → policy → validator → **safety gate** → executor →
29 redactor → audit
30
31Diagnostics
32-----------
33Domain: MCP
34Levels:
35 L2 - lifecycle: tool called, tool returned
36 L3 - request details
37 L4 - deep trace
38
39Contracts
40---------
41- Tool functions never raise for expected error conditions - they return
42 a structured error string that the MCP client sees as tool output.
43 Nothing here raises an SDK error, so a failure reaches the host as a
44 result and never as a JSON-RPC error (plan 244, P4).
45- The :func:`register_tools` function is idempotent for a given
46 :class:`~mcp.server.MCPServer` instance.
47- Tool functions always write one audit record per invocation, even on
48 policy denial or validation failure.
49- MCP SDK v2 dispatches synchronous tool handlers to worker threads and
50 lets requests overlap. Every call therefore passes through
51 ``_TOOL_CALL_LOCK`` before entering the pipeline, so the rate limiter,
52 executor, audit logger and metrics behind the server-state singleton
53 still see one caller at a time (plan 244, P3).
54
55Dependencies
56------------
57- oct.core.diagnostics (_dbg structured logger)
58- oct.mcp.audit (McpAuditRecord and the timestamp and project-root hash helpers for each audit record)
59- oct.mcp.safety (SafetyDecision and the confirmation-request text of the write-tool safety gate)
60- oct.mcp.server (get_server_state, the shared pipeline instances)
61- oct.mcp.validator (validate_tool_args, the Layer 2 argument validation)
62- oct.mcp.policy (lazy; TOOL_MANIFEST, the tool spec a write call hands the safety gate)
63- mcp (MCPServer, the SDK server the tools register on)
64"""
65
66from __future__ import annotations
67
68import getpass
69import hashlib
70import threading
71import time
72import uuid
73from pathlib import Path
74from typing import Any
75
76from mcp.server import MCPServer
77
78from oct.core.diagnostics import _dbg
79
80from oct.mcp.audit import McpAuditRecord, _hash_project_root, _utc_now_iso
81from oct.mcp.safety import SafetyDecision, _format_confirmation_request
82from oct.mcp.server import get_server_state
83from oct.mcp.validator import validate_tool_args
84
85
86# ---------------------------------------------------------------------
87# Concurrency choke point (plan 244, P3)
88# ---------------------------------------------------------------------
89
90#: Process-wide tool-call lock. MCP SDK v2 runs synchronous tool
91#: handlers on worker threads via ``anyio.to_thread.run_sync()`` and
92#: overlaps concurrent requests, but the pipeline layers reached through
93#: :func:`~oct.mcp.server.get_server_state` - rate limiter, executor,
94#: audit logger, metrics - were written for a single thread. Every tool
95#: call is serialised here so that contract still holds. Serialising is
96#: the conservative choice; making those layers individually thread-safe
97#: is separate, operator-gated work.
98#:
99#: Re-entrant so that a nested call on the same thread cannot deadlock.
100_TOOL_CALL_LOCK: threading.RLock = threading.RLock()
101
102
103# ---------------------------------------------------------------------
104# Shared pipeline runner
105# ---------------------------------------------------------------------
106
107
108def _run_tool(tool_name: str, raw_args: dict) -> str:
109 """Execute one tool through the full six-layer pipeline.
110
111 This is the single shared entry point called by every read tool
112 function. It takes ``_TOOL_CALL_LOCK`` for the whole pipeline so no
113 two calls are ever inside it at once, then delegates to
114 :func:`_run_tool_pipeline`.
115
116 Parameters
117 ----------
118 tool_name
119 MCP tool name, e.g. ``"oct_lint"``.
120 raw_args
121 Unvalidated dict from the MCP client.
122
123 Returns
124 -------
125 Redacted tool output string (or a structured error message).
126 """
127 with _TOOL_CALL_LOCK:
128 return _run_tool_pipeline(tool_name, raw_args)
129
130
131def _run_tool_pipeline(tool_name: str, raw_args: dict) -> str:
132 """Run the six-layer read pipeline; caller holds ``_TOOL_CALL_LOCK``.
133
134 It handles all error conditions and always writes an audit record.
135
136 Parameters
137 ----------
138 tool_name
139 MCP tool name, e.g. ``"oct_lint"``.
140 raw_args
141 Unvalidated dict from the MCP client.
142
143 Returns
144 -------
145 Redacted tool output string (or a structured error message).
146 """
147 func = "_run_tool"
148 state = get_server_state()
149 request_id = str(uuid.uuid4())
150 start_time = time.monotonic()
151
152 _dbg("MCP", func, f"tool={tool_name} request_id={request_id}", 2)
153
154 # Build a skeleton audit record filled in as we proceed.
155 record = McpAuditRecord(
156 timestamp=_utc_now_iso(),
157 session_id=state.session_id,
158 request_id=request_id,
159 tool=tool_name,
160 args={}, # filled after validation
161 args_redacted=True,
162 user=_safe_getuser(),
163 project_root_hash=_hash_project_root(state.project_root),
164 )
165
166 try:
167 # Layer 1: rate limiter
168 if not state.rate_limiter.consume():
169 record.decision = "denied"
170 record.policy_rule = "rate_limit"
171 record.duration_ms = _elapsed_ms(start_time)
172 state.audit_logger.write(record)
173 state.metrics.record_rate_limit_hit(tool_name)
174 return _error_response(
175 "Rate limit exceeded. Please wait before making more tool calls.",
176 tool_name,
177 )
178
179 # Layer 3: policy check (before validation, so unknown tools are rejected fast)
180 decision = state.policy.check(tool_name)
181 record.decision = decision.decision
182 record.policy_rule = decision.policy_rule
183
184 if not decision.allowed:
185 record.duration_ms = _elapsed_ms(start_time)
186 state.audit_logger.write(record)
187 state.metrics.record_call(tool_name, decision.decision,
188 _elapsed_ms(start_time) / 1000, 0)
189 return _error_response(decision.reason, tool_name)
190
191 # Layer 2: input validation (Pydantic)
192 try:
193 validated_model = validate_tool_args(
194 tool_name, raw_args, project_root=state.project_root
195 )
196 except Exception as exc:
197 record.decision = "denied"
198 record.policy_rule = "validation_failure"
199 record.duration_ms = _elapsed_ms(start_time)
200 state.audit_logger.write(record)
201 state.metrics.record_validation_failure(tool_name)
202 return _error_response(f"Input validation failed: {exc}", tool_name)
203
204 # Store redacted args in audit record.
205 record.args = validated_model.model_dump()
206
207 # Layer 4: execution sandbox
208 sandbox_result = state.executor.execute(
209 tool_name=tool_name,
210 validated_args=validated_model.model_dump(),
211 project_root=state.project_root,
212 )
213
214 raw_output = sandbox_result.stdout
215 if sandbox_result.stderr:
216 if raw_output:
217 raw_output += "\n--- stderr ---\n" + sandbox_result.stderr
218 else:
219 raw_output = sandbox_result.stderr
220
221 # Compute output hash before redaction (integrity check).
222 output_hash = hashlib.sha256(
223 raw_output.encode("utf-8", errors="replace")
224 ).hexdigest()
225
226 # Layer 5: output redaction
227 redactor_result = state.redactor.apply_all(raw_output, state.project_root)
228
229 # Layer 6: audit record completion
230 record.exit_code = sandbox_result.exit_code
231 duration_ms = _elapsed_ms(start_time)
232 record.duration_ms = duration_ms
233 record.output_size_bytes = len(
234 redactor_result.text.encode("utf-8", errors="replace")
235 )
236 record.output_truncated = (
237 sandbox_result.output_truncated or redactor_result.truncated
238 )
239 record.output_hash_sha256 = output_hash
240 record.redactions_applied = redactor_result.redactions_applied
241
242 state.audit_logger.write(record)
243 state.metrics.record_call(
244 tool_name, "allowed", duration_ms / 1000, redactor_result.redactions_applied
245 )
246
247 _dbg(
248 "MCP", func,
249 f"tool={tool_name} exit_code={sandbox_result.exit_code} "
250 f"redactions={redactor_result.redactions_applied}",
251 2,
252 )
253 return redactor_result.text
254
255 except Exception as exc: # pragma: no cover - catch-all for unexpected errors
256 _dbg("MCP", func, f"SYSTEM_ERROR: unexpected error in {tool_name}: {exc}", 1)
257 record.decision = "denied"
258 record.policy_rule = "internal_error"
259 record.duration_ms = _elapsed_ms(start_time)
260 try:
261 state.audit_logger.write(record)
262 except Exception:
263 pass
264 return _error_response(f"Internal server error: {exc}", tool_name)
265
266
267def _run_tool_write(tool_name: str, raw_args: dict) -> str:
268 """Execute one write tool through the HITL-extended pipeline.
269
270 This is the single shared entry point called by every write tool
271 function. It takes the same ``_TOOL_CALL_LOCK`` as :func:`_run_tool`
272 - one lock covers both pipelines - then delegates to
273 :func:`_run_tool_write_pipeline`.
274
275 Parameters
276 ----------
277 tool_name
278 One of the 11 Phase 5B write tool names.
279 raw_args
280 Unvalidated dict from the MCP client.
281
282 Returns
283 -------
284 Redacted tool output string, a confirmation-required string, or a
285 structured error message.
286 """
287 with _TOOL_CALL_LOCK:
288 return _run_tool_write_pipeline(tool_name, raw_args)
289
290
291def _run_tool_write_pipeline(tool_name: str, raw_args: dict) -> str:
292 """Run the write pipeline; caller holds ``_TOOL_CALL_LOCK``.
293
294 Pipeline: rate-limiter → policy → validator → safety gate →
295 executor → redactor → audit.
296
297 When the safety gate requires confirmation (``confirm=False`` in
298 args), returns a structured ``[oct-mcp requires_confirmation] …``
299 string without executing anything. The AI host must re-submit with
300 ``confirm=True`` after the user approves.
301
302 Parameters
303 ----------
304 tool_name
305 One of the 11 Phase 5B write tool names.
306 raw_args
307 Unvalidated dict from the MCP client.
308
309 Returns
310 -------
311 Redacted tool output string, a confirmation-required string, or a
312 structured error message.
313 """
314 func = "_run_tool_write"
315 state = get_server_state()
316 request_id = str(uuid.uuid4())
317 start_time = time.monotonic()
318
319 _dbg("MCP", func, f"tool={tool_name} request_id={request_id}", 2)
320
321 from oct.mcp.policy import TOOL_MANIFEST
322
323 record = McpAuditRecord(
324 timestamp=_utc_now_iso(),
325 session_id=state.session_id,
326 request_id=request_id,
327 tool=tool_name,
328 args={},
329 args_redacted=True,
330 user=_safe_getuser(),
331 project_root_hash=_hash_project_root(state.project_root),
332 )
333
334 try:
335 # Layer 1: rate limiter
336 if not state.rate_limiter.consume():
337 record.decision = "denied"
338 record.policy_rule = "rate_limit"
339 record.duration_ms = _elapsed_ms(start_time)
340 state.audit_logger.write(record)
341 state.metrics.record_rate_limit_hit(tool_name)
342 return _error_response(
343 "Rate limit exceeded. Please wait before making more tool calls.",
344 tool_name,
345 )
346
347 # Layer 3: policy check
348 decision = state.policy.check(tool_name)
349 record.decision = decision.decision
350 record.policy_rule = decision.policy_rule
351
352 if not decision.allowed:
353 record.duration_ms = _elapsed_ms(start_time)
354 state.audit_logger.write(record)
355 state.metrics.record_call(tool_name, decision.decision,
356 _elapsed_ms(start_time) / 1000, 0)
357 return _error_response(decision.reason, tool_name)
358
359 # Layer 2: input validation (Pydantic)
360 try:
361 validated_model = validate_tool_args(
362 tool_name, raw_args, project_root=state.project_root
363 )
364 except Exception as exc:
365 record.decision = "denied"
366 record.policy_rule = "validation_failure"
367 record.duration_ms = _elapsed_ms(start_time)
368 state.audit_logger.write(record)
369 state.metrics.record_validation_failure(tool_name)
370 return _error_response(f"Input validation failed: {exc}", tool_name)
371
372 record.args = validated_model.model_dump()
373
374 # Layer 2.5: HITL safety gate
375 spec = TOOL_MANIFEST.get(tool_name)
376 if spec is None: # pragma: no cover - policy already checked this
377 record.decision = "denied"
378 record.policy_rule = "unknown_tool"
379 record.duration_ms = _elapsed_ms(start_time)
380 state.audit_logger.write(record)
381 return _error_response(f"Unknown tool: {tool_name}", tool_name)
382
383 safety_decision: SafetyDecision = state.safety.check(
384 tool_name, spec, validated_model.model_dump()
385 )
386
387 if not safety_decision.proceed:
388 record.decision = "requires_confirmation"
389 record.policy_rule = "hitl_gate"
390 duration_ms = _elapsed_ms(start_time)
391 record.duration_ms = duration_ms
392 state.audit_logger.write(record)
393 state.metrics.record_call(tool_name, "requires_confirmation",
394 duration_ms / 1000, 0)
395 return _format_confirmation_request(
396 tool_name, spec, validated_model.model_dump()
397 )
398
399 # Layer 4: execution sandbox
400 sandbox_result = state.executor.execute_write(
401 tool_name=tool_name,
402 validated_args=validated_model.model_dump(),
403 project_root=state.project_root,
404 dry_run_override=safety_decision.dry_run,
405 )
406
407 raw_output = sandbox_result.stdout
408 if sandbox_result.stderr:
409 if raw_output:
410 raw_output += "\n--- stderr ---\n" + sandbox_result.stderr
411 else:
412 raw_output = sandbox_result.stderr
413
414 output_hash = hashlib.sha256(
415 raw_output.encode("utf-8", errors="replace")
416 ).hexdigest()
417
418 # Layer 5: output redaction
419 redactor_result = state.redactor.apply_all(raw_output, state.project_root)
420
421 # Layer 6: audit record completion
422 record.decision = "allowed"
423 record.exit_code = sandbox_result.exit_code
424 duration_ms = _elapsed_ms(start_time)
425 record.duration_ms = duration_ms
426 record.output_size_bytes = len(
427 redactor_result.text.encode("utf-8", errors="replace")
428 )
429 record.output_truncated = (
430 sandbox_result.output_truncated or redactor_result.truncated
431 )
432 record.output_hash_sha256 = output_hash
433 record.redactions_applied = redactor_result.redactions_applied
434
435 state.audit_logger.write(record)
436 state.metrics.record_call(
437 tool_name, "allowed", duration_ms / 1000, redactor_result.redactions_applied
438 )
439
440 _dbg(
441 "MCP", func,
442 f"tool={tool_name} exit_code={sandbox_result.exit_code} "
443 f"redactions={redactor_result.redactions_applied}",
444 2,
445 )
446 return redactor_result.text
447
448 except Exception as exc: # pragma: no cover - catch-all for unexpected errors
449 _dbg("MCP", func, f"SYSTEM_ERROR: unexpected error in {tool_name}: {exc}", 1)
450 record.decision = "denied"
451 record.policy_rule = "internal_error"
452 record.duration_ms = _elapsed_ms(start_time)
453 try:
454 state.audit_logger.write(record)
455 except Exception:
456 pass
457 return _error_response(f"Internal server error: {exc}", tool_name)
458
459
460def _elapsed_ms(start: float) -> int:
461 return int((time.monotonic() - start) * 1000)
462
463
464def _safe_getuser() -> str:
465 try:
466 return getpass.getuser()
467 except Exception:
468 return ""
469
470
471def _error_response(message: str, tool_name: str) -> str:
472 """Return a structured error string visible to the MCP client."""
473 return f"[oct-mcp error] {tool_name}: {message}"
474
475
476# ---------------------------------------------------------------------
477# Tool registration
478# ---------------------------------------------------------------------
479
480
481def register_tools(mcp: MCPServer) -> None:
482 """Register all 9 Phase 5A tools with the MCP server instance.
483
484 Called once during :func:`~oct.mcp.server.build_mcp_server`.
485 """
486
487 @mcp.tool(
488 name="oct_lint",
489 description=(
490 "Run the Option C linter on the project. Returns a JSON summary "
491 "of lint violations including header checks, secret detection, "
492 "and coding style enforcement. Use `changed=true` to lint only "
493 "git-modified files."
494 ),
495 )
496 def oct_lint(
497 paths: list[str] | None = None,
498 json_output: bool = True,
499 jobs: int = 1,
500 changed: bool = False,
501 fix_headers: bool = False,
502 profile: str | None = None,
503 ) -> str:
504 args: dict = {
505 "paths": paths or [],
506 "json_output": json_output,
507 "jobs": jobs,
508 "changed": changed,
509 "fix_headers": fix_headers,
510 "profile": profile,
511 }
512 return _run_tool("oct_lint", args)
513
514 @mcp.tool(
515 name="oct_health",
516 description=(
517 "Run the Option C project health check. Returns a summary of "
518 "project structure integrity, configuration validity, and any "
519 "detected issues."
520 ),
521 )
522 def oct_health(
523 json_output: bool = True,
524 verbose: bool = False,
525 ) -> str:
526 return _run_tool("oct_health", {"json_output": json_output, "verbose": verbose})
527
528 @mcp.tool(
529 name="oct_skeleton",
530 description=(
531 "Export the project source skeleton (file tree with content "
532 "stubs). Returns a JSON manifest of exported files. Sensitive "
533 "files (.env, *.key, etc.) are automatically excluded."
534 ),
535 )
536 def oct_skeleton(
537 json_output: bool = True,
538 output_dir: str | None = None,
539 ) -> str:
540 return _run_tool("oct_skeleton", {"json_output": json_output, "output_dir": output_dir})
541
542 @mcp.tool(
543 name="oct_deps",
544 description=(
545 "Inspect project dependencies. Returns a JSON summary of "
546 "installed packages, missing dependencies, and version mismatches."
547 ),
548 )
549 def oct_deps(
550 json_output: bool = True,
551 check: bool = False,
552 ) -> str:
553 return _run_tool("oct_deps", {"json_output": json_output, "check": check})
554
555 @mcp.tool(
556 name="oct_test",
557 description=(
558 "Run the project test suite using pytest. Returns a JSON summary "
559 "of test results including pass/fail counts and short failure "
560 "tracebacks. Use `changed=true` to run only tests related to "
561 "git-modified files."
562 ),
563 )
564 def oct_test(
565 json_output: bool = True,
566 paths: list[str] | None = None,
567 changed: bool = False,
568 jobs: int = 1,
569 ) -> str:
570 args: dict = {
571 "json_output": json_output,
572 "paths": paths or [],
573 "changed": changed,
574 "jobs": jobs,
575 }
576 return _run_tool("oct_test", args)
577
578 @mcp.tool(
579 name="oct_typecheck",
580 description=(
581 "Run type checking on the project using pyright (falls back to "
582 "mypy if pyright is not installed). Returns a JSON object with "
583 "the tool used, output, and exit code."
584 ),
585 )
586 def oct_typecheck(
587 json_output: bool = True,
588 paths: list[str] | None = None,
589 ) -> str:
590 args: dict = {
591 "json_output": json_output,
592 "paths": paths or [],
593 }
594 return _run_tool("oct_typecheck", args)
595
596 @mcp.tool(
597 name="oct_diag",
598 description=(
599 # OCT-LINT: disable=no_hardcoded_secrets reason="MCP tool description prose read by the AI host to choose a tool, not a credential"
600 "Run Option C diagnostics. Available subcommands: "
601 "'validate-config' (check .octrc.json), "
602 "'show-config' (display resolved config), "
603 "'list-checks' (list available lint checks)."
604 ),
605 )
606 def oct_diag(
607 json_output: bool = True,
608 subcommand: str = "validate-config",
609 ) -> str:
610 return _run_tool("oct_diag", {"json_output": json_output, "subcommand": subcommand})
611
612 @mcp.tool(
613 name="oct_git_status",
614 description=(
615 "Show the git status of the project: staged files, modified "
616 "files, branch name, and commit counts. Returns a JSON summary."
617 ),
618 )
619 def oct_git_status(
620 json_output: bool = True,
621 verbose: bool = False,
622 ) -> str:
623 return _run_tool("oct_git_status", {"json_output": json_output, "verbose": verbose})
624
625 @mcp.tool(
626 name="oct_git_check",
627 description=(
628 "Run the Option C git pre-commit quality gate check. Verifies "
629 "that staged changes pass lint, format, and secret-detection "
630 "checks. Returns a JSON summary of results."
631 ),
632 )
633 def oct_git_check(
634 json_output: bool = True,
635 verbose: bool = False,
636 ) -> str:
637 return _run_tool("oct_git_check", {"json_output": json_output, "verbose": verbose})
638
639
640def register_write_tools(mcp: MCPServer) -> None:
641 """Register all 11 Phase 5B write tools with the MCP server instance.
642
643 All write tools require the user to confirm with ``confirm=True``
644 before execution proceeds (HITL gate). Without confirmation the tool
645 returns a ``[oct-mcp requires_confirmation]`` string describing what
646 would happen.
647
648 Called once during :func:`~oct.mcp.server.build_mcp_server`.
649 """
650
651 @mcp.tool(
652 name="oct_format",
653 description=(
654 # OCT-LINT: disable=no_hardcoded_secrets reason="MCP tool description prose read by the AI host to choose a tool, not a credential"
655 "Auto-fix Option C compliance violations in Python files. "
656 "REQUIRES CONFIRMATION: call with confirm=True to proceed. "
657 "Set apply=True AND confirm=True to write changes. "
658 "With apply=False (default) runs in --dry-run mode even when confirmed."
659 ),
660 )
661 def oct_format(
662 paths: list[str] | None = None,
663 apply: bool = False,
664 confirm: bool = False,
665 changed: bool = False,
666 verbose: bool = False,
667 jobs: int = 1,
668 json_output: bool = True,
669 ) -> str:
670 return _run_tool_write("oct_format", {
671 "paths": paths or [], "apply": apply, "confirm": confirm,
672 "changed": changed, "verbose": verbose, "jobs": jobs,
673 "json_output": json_output,
674 })
675
676 @mcp.tool(
677 name="oct_docs",
678 description=(
679 # OCT-LINT: disable=no_hardcoded_secrets reason="MCP tool description prose read by the AI host to choose a tool, not a credential"
680 "Generate or clean Option C documentation. "
681 "REQUIRES CONFIRMATION: call with confirm=True to proceed. "
682 "Use sphinx=True, doxygen=True, or all_docs=True to select output. "
683 "dry_run=True (default) previews without generating files."
684 ),
685 )
686 def oct_docs(
687 sphinx: bool = False,
688 doxygen: bool = False,
689 all_docs: bool = False,
690 clean: bool = False,
691 dry_run: bool = True,
692 confirm: bool = False,
693 ) -> str:
694 return _run_tool_write("oct_docs", {
695 "sphinx": sphinx, "doxygen": doxygen, "all_docs": all_docs,
696 "clean": clean, "dry_run": dry_run, "confirm": confirm,
697 })
698
699 @mcp.tool(
700 name="oct_export_source",
701 description=(
702 # OCT-LINT: disable=no_hardcoded_secrets reason="MCP tool description prose read by the AI host to choose a tool, not a credential"
703 "Export consolidated source-code files for inspection. "
704 "REQUIRES CONFIRMATION: call with confirm=True to proceed. "
705 "Writes _source_code-* export directories to the project root."
706 ),
707 )
708 def oct_export_source(
709 confirm: bool = False,
710 verbose: bool = False,
711 single_dir: bool = False,
712 warn_secrets: bool = True,
713 block_secrets: bool = False,
714 ) -> str:
715 return _run_tool_write("oct_export_source", {
716 "confirm": confirm, "verbose": verbose, "single_dir": single_dir,
717 "warn_secrets": warn_secrets, "block_secrets": block_secrets,
718 })
719
720 @mcp.tool(
721 name="oct_new",
722 description=(
723 # OCT-LINT: disable=no_hardcoded_secrets reason="MCP tool description prose read by the AI host to choose a tool, not a credential"
724 "Create a new Option C-compliant Python source file. "
725 "REQUIRES CONFIRMATION: call with confirm=True to proceed. "
726 "dry_run=True (default) shows what would be created without writing."
727 ),
728 )
729 def oct_new(
730 path: str = "",
731 confirm: bool = False,
732 force: bool = False,
733 create_test: bool = False,
734 dry_run: bool = True,
735 verbose: bool = False,
736 ) -> str:
737 return _run_tool_write("oct_new", {
738 "path": path, "confirm": confirm, "force": force,
739 "create_test": create_test, "dry_run": dry_run, "verbose": verbose,
740 })
741
742 @mcp.tool(
743 name="oct_scaffold",
744 description=(
745 # OCT-LINT: disable=no_hardcoded_secrets reason="MCP tool description prose read by the AI host to choose a tool, not a credential"
746 "Create a new Option C project directory tree. "
747 "REQUIRES CONFIRMATION: call with confirm=True to proceed. "
748 "dry_run=True (default) shows what would be created without writing."
749 ),
750 )
751 def oct_scaffold(
752 name: str = "",
753 confirm: bool = False,
754 dry_run: bool = True,
755 ) -> str:
756 return _run_tool_write("oct_scaffold", {
757 "name": name, "confirm": confirm, "dry_run": dry_run,
758 })
759
760 @mcp.tool(
761 name="oct_clean",
762 description=(
763 # OCT-LINT: disable=no_hardcoded_secrets reason="MCP tool description prose read by the AI host to choose a tool, not a credential"
764 "Remove _source_code-*, _skeleton_code-*, and __pycache__ "
765 "directories from the project. "
766 "REQUIRES CONFIRMATION: call with confirm=True to proceed. "
767 "dry_run=True (default) shows what would be deleted without deleting."
768 ),
769 )
770 def oct_clean(
771 path: str | None = None,
772 confirm: bool = False,
773 dry_run: bool = True,
774 ) -> str:
775 return _run_tool_write("oct_clean", {
776 "path": path, "confirm": confirm, "dry_run": dry_run,
777 })
778
779 @mcp.tool(
780 name="oct_install_hooks",
781 description=(
782 # OCT-LINT: disable=no_hardcoded_secrets reason="MCP tool description prose read by the AI host to choose a tool, not a credential"
783 "Install Option C pre-commit hooks configuration. "
784 "REQUIRES CONFIRMATION: call with confirm=True to proceed. "
785 "Writes .pre-commit-config.yaml to the project root."
786 ),
787 )
788 def oct_install_hooks(
789 confirm: bool = False,
790 force: bool = False,
791 ) -> str:
792 return _run_tool_write("oct_install_hooks", {
793 "confirm": confirm, "force": force,
794 })
795
796 @mcp.tool(
797 name="oct_git_commit",
798 description=(
799 # OCT-LINT: disable=no_hardcoded_secrets reason="MCP tool description prose read by the AI host to choose a tool, not a credential"
800 "Commit staged changes with Option C quality gate enforcement. "
801 "REQUIRES CONFIRMATION: call with confirm=True to proceed. "
802 "Validates Conventional Commits format and runs lint/format checks. "
803 "Note: --no-verify is not available via MCP."
804 ),
805 )
806 def oct_git_commit(
807 message: str = "",
808 confirm: bool = False,
809 force: bool = False,
810 json_output: bool = True,
811 ) -> str:
812 return _run_tool_write("oct_git_commit", {
813 "message": message, "confirm": confirm, "force": force,
814 "json_output": json_output,
815 })
816
817 @mcp.tool(
818 name="oct_git_hooks_install",
819 description=(
820 # OCT-LINT: disable=no_hardcoded_secrets reason="MCP tool description prose read by the AI host to choose a tool, not a credential"
821 "Install Option C git pre-commit hooks via 'oct git hooks install'. "
822 "REQUIRES CONFIRMATION: call with confirm=True to proceed."
823 ),
824 )
825 def oct_git_hooks_install(
826 confirm: bool = False,
827 force: bool = False,
828 ) -> str:
829 return _run_tool_write("oct_git_hooks_install", {
830 "confirm": confirm, "force": force,
831 })
832
833 @mcp.tool(
834 name="oct_git_init",
835 description=(
836 # OCT-LINT: disable=no_hardcoded_secrets reason="MCP tool description prose read by the AI host to choose a tool, not a credential"
837 "Initialise or enhance a git repository with Option C conventions "
838 "(gitignore, gitattributes, hooks). "
839 "REQUIRES CONFIRMATION: call with confirm=True to proceed. "
840 "dry_run=True (default) shows what would change without modifying."
841 ),
842 )
843 def oct_git_init(
844 confirm: bool = False,
845 dry_run: bool = True,
846 github: bool = False,
847 no_hooks: bool = False,
848 ) -> str:
849 return _run_tool_write("oct_git_init", {
850 "confirm": confirm, "dry_run": dry_run,
851 "github": github, "no_hooks": no_hooks,
852 })
853
854 @mcp.tool(
855 name="oct_git_changelog",
856 description=(
857 # OCT-LINT: disable=no_hardcoded_secrets reason="MCP tool description prose read by the AI host to choose a tool, not a credential"
858 "Generate CHANGELOG.md from Conventional Commits history. "
859 "REQUIRES CONFIRMATION: call with confirm=True to proceed. "
860 "dry_run=True (default) prints to stdout without writing the file."
861 ),
862 )
863 def oct_git_changelog(
864 confirm: bool = False,
865 dry_run: bool = True,
866 since: str | None = None,
867 version: str | None = None,
868 json_output: bool = True,
869 ) -> str:
870 return _run_tool_write("oct_git_changelog", {
871 "confirm": confirm, "dry_run": dry_run,
872 "since": since, "version": version, "json_output": json_output,
873 })
None register_write_tools(MCPServer mcp)
Definition tools.py:640
int _elapsed_ms(float start)
Definition tools.py:460
str _run_tool_write(str tool_name, dict raw_args)
Definition tools.py:267
str _run_tool_pipeline(str tool_name, dict raw_args)
Definition tools.py:131
str _safe_getuser()
Definition tools.py:464
str _run_tool(str tool_name, dict raw_args)
Definition tools.py:108
str _error_response(str message, str tool_name)
Definition tools.py:471
None register_tools(MCPServer mcp)
Definition tools.py:481
str _run_tool_write_pipeline(str tool_name, dict raw_args)
Definition tools.py:291