From 71ee150da683ca38f783f331a5374f00201424db Mon Sep 17 00:00:00 2001 From: Kenneth Belitzky Date: Fri, 8 May 2026 00:03:51 -0300 Subject: [PATCH 1/2] Add lint command for structure definitions --- docs/cli-reference.md | 37 +++++ structkit/commands/lint.py | 327 +++++++++++++++++++++++++++++++++++++ structkit/main.py | 2 + tests/test_lint_command.py | 138 ++++++++++++++++ 4 files changed, 504 insertions(+) create mode 100644 structkit/commands/lint.py create mode 100644 tests/test_lint_command.py diff --git a/docs/cli-reference.md b/docs/cli-reference.md index d8d0089..89f1546 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -80,6 +80,43 @@ structkit validate [-h] [-l LOG] [-c CONFIG_FILE] [-i LOG_FILE] yaml_file - `yaml_file`: Path to the YAML configuration file. +### `lint` + +Lint one or more StructKit YAML definitions for quality checks that are stricter than syntactic validation. The command reports warnings and errors with file paths and context. It exits with status code `1` when one or more lint errors are found, and exits `0` when only warnings or no issues are found. + +**Usage:** + +```sh +structkit lint [-h] [-l LOG] [-c CONFIG_FILE] [-i LOG_FILE] [-s STRUCTURES_PATH] [--all] [--json] [targets ...] +``` + +**Arguments:** + +- `targets`: YAML file paths, `file://` YAML URLs, or bundled/custom structure names. Multiple targets are supported. +- `--all`: Lint all bundled contrib structures, plus custom structures when `--structures-path` is supplied. +- `-s STRUCTURES_PATH, --structures-path STRUCTURES_PATH`: Path to custom structure definitions. +- `--json`: Print machine-readable JSON with a summary and issue list. + +**Lint rules:** + +- Missing top-level `description` (warning). +- Variables referenced in StructKit templates but not declared (error). +- Declared variables that are never referenced (warning). +- Duplicate file or folder entries (error). +- Unsafe hooks, such as destructive shell patterns (error), and suspicious hooks, such as `curl | bash`, `sudo`, `eval`, or `chmod 777` (warning). +- GitHub remote URLs that do not appear pinned to a stable tag, release, or commit SHA (warning). +- Invalid entry names that are absolute paths or escape with `..` (error), and unusual name characters (warning). + +**Examples:** + +```sh +structkit lint .struct.yaml +structkit lint structkit/contribs/project/python.yaml +structkit lint project/python +structkit lint --all +structkit lint .struct.yaml --json +``` + ### `generate` Generate the project structure. diff --git a/structkit/commands/lint.py b/structkit/commands/lint.py new file mode 100644 index 0000000..716e254 --- /dev/null +++ b/structkit/commands/lint.py @@ -0,0 +1,327 @@ +import json +import os +import re +from dataclasses import asdict, dataclass + +import yaml +from jinja2 import Environment, TemplateSyntaxError, meta + +from structkit.commands import Command +from structkit.commands.validate import ValidateCommand + + +class _NoopLogger: + def info(self, *_args, **_kwargs): + pass + + +@dataclass +class LintIssue: + severity: str + rule: str + message: str + path: str + context: str = "" + + +class LintCommand(Command): + """Lint StructKit YAML files for quality issues beyond schema validity.""" + + STABLE_GIT_REF_RE = re.compile(r"^[0-9a-f]{40}$|^v?\d+\.\d+(?:\.\d+)?(?:[-+][0-9A-Za-z.-]+)?$") + SUSPICIOUS_HOOK_PATTERNS = [ + re.compile(r"\bcurl\b.*\|\s*(?:ba)?sh\b"), + re.compile(r"\bwget\b.*\|\s*(?:ba)?sh\b"), + re.compile(r"\beval\b"), + re.compile(r"\bchmod\s+777\b"), + re.compile(r"\bsudo\b"), + ] + UNSAFE_HOOK_PATTERNS = [ + re.compile(r"\brm\s+-rf\s+/(?:\s|$)"), + re.compile(r"\brm\s+-rf\s+\$\{?\w+\}?"), + re.compile(r":\(\)\s*\{\s*:\|:"), + ] + REMOTE_URL_RE = re.compile(r"https?://[^\s'\"]+") + NAME_RE = re.compile(r"^[A-Za-z0-9._@{}%/+=, -]+$") + + def __init__(self, parser): + super().__init__(parser) + parser.description = "Lint StructKit YAML definitions for quality issues" + target = parser.add_argument( + 'targets', + nargs='*', + help='YAML file paths, file:// URLs, or bundled/custom structure names to lint', + ) + from structkit.completers import structures_completer + target.completer = structures_completer + parser.add_argument( + '--all', + action='store_true', + help='Lint all bundled contrib structures plus custom structures when --structures-path is set', + ) + parser.add_argument( + '-s', '--structures-path', + type=str, + help='Path to custom structure definitions (env: STRUCTKIT_STRUCTURES_PATH)', + default=os.getenv('STRUCTKIT_STRUCTURES_PATH', None), + ) + parser.add_argument('--json', action='store_true', help='Print machine-readable JSON output') + parser.set_defaults(func=self.execute) + + self.template_env = Environment( + trim_blocks=True, + block_start_string='{%@', + block_end_string='@%}', + variable_start_string='{{@', + variable_end_string='@}}', + comment_start_string='{#@', + comment_end_string='@#}', + ) + self.template_env.globals.update({ + 'current_repo': lambda: None, + 'uuid': lambda: None, + 'now': lambda: None, + 'env': lambda *_args, **_kwargs: None, + 'read_file': lambda *_args, **_kwargs: None, + }) + self.template_env.filters.update({ + 'latest_release': lambda value: value, + 'slugify': lambda value: value, + 'default_branch': lambda value: value, + 'to_yaml': lambda value: value, + 'from_yaml': lambda value: value, + 'to_json': lambda value: value, + 'from_json': lambda value: value, + }) + + def execute(self, args): + targets = self._resolve_targets(args) + issues = [] + + if not targets: + issues.append(LintIssue('error', 'missing-target', 'Provide at least one target or use --all.', '')) + for target in targets: + issues.extend(self.lint_file(target)) + + if args.json: + self._print_json(issues) + else: + self._print_text(issues) + + if any(issue.severity == 'error' for issue in issues): + raise SystemExit(1) + + def _contribs_path(self): + this_file = os.path.dirname(os.path.realpath(__file__)) + return os.path.join(this_file, '..', 'contribs') + + def _resolve_targets(self, args): + if args.all: + roots = [self._contribs_path()] + if args.structures_path: + roots.insert(0, args.structures_path) + return self._find_yaml_files(roots) + + targets = [] + for target in args.targets: + targets.append(self._resolve_target(target, args.structures_path)) + return targets + + def _find_yaml_files(self, roots): + files = [] + seen = set() + for root in roots: + if not root or not os.path.exists(root): + continue + for dirpath, _, filenames in os.walk(root): + for filename in filenames: + if filename.endswith(('.yaml', '.yml')): + path = os.path.join(dirpath, filename) + if path not in seen: + files.append(path) + seen.add(path) + return sorted(files) + + def _resolve_target(self, target, structures_path=None): + if target.startswith('file://'): + return target[7:] + if target.endswith(('.yaml', '.yml')) or os.path.exists(target): + return target + + candidates = [] + if structures_path: + candidates.append(os.path.join(structures_path, f'{target}.yaml')) + candidates.append(os.path.join(structures_path, f'{target}.yml')) + candidates.append(os.path.join(self._contribs_path(), f'{target}.yaml')) + candidates.append(os.path.join(self._contribs_path(), f'{target}.yml')) + for candidate in candidates: + if os.path.exists(candidate): + return candidate + return target + + def lint_file(self, path): + issues = [] + if not os.path.exists(path): + return [LintIssue('error', 'not-found', f'Could not find structure target: {path}', path)] + + try: + with open(path, 'r') as f: + config = yaml.safe_load(f) or {} + except yaml.YAMLError as exc: + return [LintIssue('error', 'invalid-yaml', f'YAML could not be parsed: {exc}', path)] + except OSError as exc: + return [LintIssue('error', 'read-error', f'File could not be read: {exc}', path)] + + if not isinstance(config, dict): + return [LintIssue('error', 'invalid-root', 'Top-level YAML document must be a mapping.', path)] + + issues.extend(self._validate_baseline(config, path)) + issues.extend(self._check_description(config, path)) + issues.extend(self._check_duplicates(config, path)) + issues.extend(self._check_templates(config, path)) + issues.extend(self._check_hooks(config, path)) + issues.extend(self._check_remote_urls(config, path)) + issues.extend(self._check_names(config, path)) + return issues + + def _validate_baseline(self, config, path): + validator = ValidateCommand.__new__(ValidateCommand) + validator.logger = _NoopLogger() + try: + validator._validate_structure_config(config.get('structure') or config.get('files', [])) + validator._validate_folders_config(config.get('folders', [])) + validator._validate_variables_config(config.get('variables', [])) + except ValueError as exc: + return [LintIssue('error', 'validate', str(exc), path)] + return [] + + def _check_description(self, config, path): + description = config.get('description') + if not isinstance(description, str) or not description.strip(): + return [LintIssue('warning', 'missing-description', 'Missing top-level description.', path)] + return [] + + def _check_duplicates(self, config, path): + issues = [] + for section, rule in (('files', 'duplicate-file'), ('structure', 'duplicate-file'), ('folders', 'duplicate-folder')): + seen = {} + for index, item in enumerate(config.get(section, []) or []): + if not isinstance(item, dict): + continue + for name in item: + if name in seen: + issues.append(LintIssue('error', rule, f"Duplicate {section[:-1]} entry '{name}'.", path, f'{section}[{index}]')) + else: + seen[name] = index + return issues + + def _check_templates(self, config, path): + issues = [] + declared = self._declared_variables(config) + referenced = set() + for context, value in self._walk_strings(config, skip_keys={'variables'}): + try: + parsed = self.template_env.parse(value) + except TemplateSyntaxError as exc: + issues.append(LintIssue('error', 'template-syntax', f'Template syntax error: {exc.message}', path, context)) + continue + referenced.update(meta.find_undeclared_variables(parsed)) + + referenced -= {'mappings'} + for name in sorted(referenced - declared): + issues.append(LintIssue('error', 'undeclared-variable', f"Variable '{name}' is referenced but not declared.", path)) + for name in sorted(declared - referenced): + issues.append(LintIssue('warning', 'unused-variable', f"Variable '{name}' is declared but never referenced.", path)) + return issues + + def _declared_variables(self, config): + declared = set() + for item in config.get('variables', []) or []: + if isinstance(item, dict): + declared.update(str(name) for name in item.keys()) + return declared + + def _walk_strings(self, value, context='', skip_keys=None): + skip_keys = skip_keys or set() + if isinstance(value, str): + yield context, value + elif isinstance(value, list): + for index, item in enumerate(value): + yield from self._walk_strings(item, f'{context}[{index}]', skip_keys) + elif isinstance(value, dict): + for key, item in value.items(): + key_context = f'{context}.{key}' if context else str(key) + if key in skip_keys: + continue + if isinstance(key, str): + yield key_context, key + yield from self._walk_strings(item, key_context, skip_keys) + + def _check_hooks(self, config, path): + issues = [] + for hook_key in ('pre_hooks', 'post_hooks'): + for index, hook in enumerate(config.get(hook_key, []) or []): + if not isinstance(hook, str): + continue + context = f'{hook_key}[{index}]' + if any(pattern.search(hook) for pattern in self.UNSAFE_HOOK_PATTERNS): + issues.append(LintIssue('error', 'unsafe-hook', 'Hook contains an unsafe destructive command.', path, context)) + elif any(pattern.search(hook) for pattern in self.SUSPICIOUS_HOOK_PATTERNS): + issues.append(LintIssue('warning', 'suspicious-hook', 'Hook contains a suspicious shell pattern; review before use.', path, context)) + return issues + + def _check_remote_urls(self, config, path): + issues = [] + for context, value in self._walk_strings(config, skip_keys={'variables'}): + for url in self.REMOTE_URL_RE.findall(value): + if self._is_unpinned_url(url): + issues.append(LintIssue('warning', 'unpinned-remote-url', 'Remote URL does not appear pinned to a stable ref.', path, context)) + return issues + + def _is_unpinned_url(self, url): + if 'github.com' not in url and 'raw.githubusercontent.com' not in url: + return False + if '/releases/download/' in url: + return False + raw_match = re.search(r'raw\.githubusercontent\.com/[^/]+/[^/]+/([^/]+)/', url) + if raw_match: + return not bool(self.STABLE_GIT_REF_RE.match(raw_match.group(1))) + ref_match = re.search(r'[?&]ref=([^&]+)', url) + if ref_match: + return not bool(self.STABLE_GIT_REF_RE.match(ref_match.group(1))) + return any(branch in url for branch in ('/main/', '/master/', '/HEAD/', '/develop/')) or not re.search(r'/[0-9a-f]{40}/|/v?\d+\.\d+', url) + + def _check_names(self, config, path): + issues = [] + for section in ('files', 'structure', 'folders'): + for index, item in enumerate(config.get(section, []) or []): + if not isinstance(item, dict): + continue + for name in item: + if name.startswith('/') or '..' in name.split('/'): + issues.append(LintIssue('error', 'invalid-name', f"Entry name '{name}' must be relative and stay within the target directory.", path, f'{section}[{index}]')) + elif '\\' in name or not self.NAME_RE.match(str(name)): + issues.append(LintIssue('warning', 'naming-convention', f"Entry name '{name}' uses unusual characters.", path, f'{section}[{index}]')) + return issues + + def _print_json(self, issues): + payload = { + 'summary': { + 'errors': sum(1 for issue in issues if issue.severity == 'error'), + 'warnings': sum(1 for issue in issues if issue.severity == 'warning'), + }, + 'issues': [asdict(issue) for issue in issues], + } + print(json.dumps(payload, indent=2, sort_keys=True)) + + def _print_text(self, issues): + if not issues: + print('✅ No lint issues found.') + return + + for issue in issues: + label = 'ERROR' if issue.severity == 'error' else 'WARN' + context = f' [{issue.context}]' if issue.context else '' + print(f'{label} {issue.path}{context}: {issue.message} ({issue.rule})') + errors = sum(1 for issue in issues if issue.severity == 'error') + warnings = sum(1 for issue in issues if issue.severity == 'warning') + print(f'\nLint summary: {errors} error(s), {warnings} warning(s).') diff --git a/structkit/main.py b/structkit/main.py index 73e499c..cba3891 100644 --- a/structkit/main.py +++ b/structkit/main.py @@ -6,6 +6,7 @@ from structkit.commands.generate import GenerateCommand from structkit.commands.info import InfoCommand from structkit.commands.validate import ValidateCommand +from structkit.commands.lint import LintCommand from structkit.commands.list import ListCommand from structkit.commands.search import SearchCommand from structkit.commands.generate_schema import GenerateSchemaCommand @@ -33,6 +34,7 @@ def get_parser(): InfoCommand(subparsers.add_parser('info', help='Show information about the package')) ValidateCommand(subparsers.add_parser('validate', help='Validate the YAML configuration file')) + LintCommand(subparsers.add_parser('lint', help='Lint YAML structure definitions for quality issues')) GenerateCommand(subparsers.add_parser('generate', help='Generate the project structure')) ListCommand(subparsers.add_parser('list', help='List available structures')) SearchCommand(subparsers.add_parser('search', help='Search available structures by keyword')) diff --git a/tests/test_lint_command.py b/tests/test_lint_command.py new file mode 100644 index 0000000..761ed81 --- /dev/null +++ b/tests/test_lint_command.py @@ -0,0 +1,138 @@ +import argparse +import json +from unittest.mock import patch + +import pytest + +from structkit.commands.lint import LintCommand +from structkit.main import get_parser + + +@pytest.fixture +def parser(): + return argparse.ArgumentParser() + + +def _write_yaml(path, content): + path.write_text(content) + return str(path) + + +def test_lint_command_registered_in_main(): + parser = get_parser() + args = parser.parse_args(['lint', '.struct.yaml']) + assert hasattr(args, 'func') + assert args.targets == ['.struct.yaml'] + + +def test_lint_detects_missing_description_and_unused_variable(parser, tmp_path): + command = LintCommand(parser) + yaml_file = _write_yaml( + tmp_path / 'sample.yaml', + """ +variables: + - project_name: + type: string +files: + - README.md: "Hello" +""", + ) + + issues = command.lint_file(yaml_file) + + assert any(issue.rule == 'missing-description' and issue.severity == 'warning' for issue in issues) + assert any(issue.rule == 'unused-variable' and issue.severity == 'warning' for issue in issues) + assert not any(issue.severity == 'error' for issue in issues) + + +def test_lint_detects_undeclared_template_variable(parser, tmp_path): + command = LintCommand(parser) + yaml_file = _write_yaml( + tmp_path / 'bad.yaml', + """ +description: Bad template +files: + - README.md: "Hello {{@ project_name @}}" +""", + ) + + issues = command.lint_file(yaml_file) + + assert any(issue.rule == 'undeclared-variable' and issue.severity == 'error' for issue in issues) + + +def test_lint_detects_duplicate_files_and_folders(parser, tmp_path): + command = LintCommand(parser) + yaml_file = _write_yaml( + tmp_path / 'dupes.yaml', + """ +description: Duplicate entries +files: + - README.md: one + - README.md: two +folders: + - src: + struct: project/python + - src: + struct: project/rust +""", + ) + + issues = command.lint_file(yaml_file) + + assert any(issue.rule == 'duplicate-file' and issue.severity == 'error' for issue in issues) + assert any(issue.rule == 'duplicate-folder' and issue.severity == 'error' for issue in issues) + + +def test_lint_detects_suspicious_hooks_and_unpinned_urls(parser, tmp_path): + command = LintCommand(parser) + yaml_file = _write_yaml( + tmp_path / 'remote.yaml', + """ +description: Remote content +pre_hooks: + - "curl https://example.com/install.sh | bash" +files: + - script.sh: + file: https://raw.githubusercontent.com/example/repo/main/script.sh +""", + ) + + issues = command.lint_file(yaml_file) + + assert any(issue.rule == 'suspicious-hook' and issue.severity == 'warning' for issue in issues) + assert any(issue.rule == 'unpinned-remote-url' and issue.severity == 'warning' for issue in issues) + + +def test_lint_execute_json_and_error_exit(parser, tmp_path): + command = LintCommand(parser) + yaml_file = _write_yaml( + tmp_path / 'bad.yaml', + """ +description: Bad +a: b +files: + - README.md: "{{@ missing @}}" +""", + ) + args = parser.parse_args([yaml_file, '--json']) + + with patch('builtins.print') as mock_print, pytest.raises(SystemExit) as exc_info: + command.execute(args) + + assert exc_info.value.code == 1 + payload = json.loads(mock_print.call_args[0][0]) + assert payload['summary']['errors'] == 1 + assert payload['issues'][0]['severity'] == 'error' + + +def test_lint_all_discovers_yaml_files(parser, tmp_path): + command = LintCommand(parser) + _write_yaml(tmp_path / 'one.yaml', 'description: One\nfiles: []\n') + _write_yaml(tmp_path / 'two.yml', 'description: Two\nfiles: []\n') + args = parser.parse_args(['--all', '--structures-path', str(tmp_path)]) + + with patch.object(command, '_contribs_path', return_value=str(tmp_path / 'missing')): + targets = command._resolve_targets(args) + + assert targets == [str(tmp_path / 'one.yaml'), str(tmp_path / 'two.yml')] From 11f7aeb13204246fb773773b8a3f1307633c6aed Mon Sep 17 00:00:00 2001 From: Kenneth Belitzky Date: Sat, 9 May 2026 00:22:05 -0300 Subject: [PATCH 2/2] Expose lint command through MCP --- docs/mcp-integration.md | 34 +++++++++++++++++++ structkit/commands/mcp.py | 1 + structkit/mcp_server.py | 62 ++++++++++++++++++++++++++++++++++- tests/test_mcp_integration.py | 36 ++++++++++++++++++++ 4 files changed, 132 insertions(+), 1 deletion(-) diff --git a/docs/mcp-integration.md b/docs/mcp-integration.md index a47178c..35ee10d 100644 --- a/docs/mcp-integration.md +++ b/docs/mcp-integration.md @@ -84,6 +84,27 @@ Validate a structure configuration YAML file. **Parameters:** - `yaml_file` (required): Path to the YAML configuration file to validate +### 5. lint_structure +Lint one or more structure configuration YAML files for quality and safety issues. + +```json +{ + "name": "lint_structure", + "arguments": { + "targets": ["/path/to/structure.yaml"], + "structures_path": "/path/to/custom/structures", + "lint_all": false, + "json_output": true + } +} +``` + +**Parameters:** +- `targets` (optional): List of YAML file paths, `file://` URLs, or bundled/custom structure names to lint +- `structures_path` (optional): Custom path to structure definitions +- `lint_all` (optional): Lint all bundled structures, plus custom structures when `structures_path` is supplied (default: false) +- `json_output` (optional): Return a JSON report with summary counts and issue details (default: false) + ## Usage ### Starting the MCP Server (FastMCP stdio / http / sse) @@ -227,6 +248,7 @@ The MCP tools can be chained together for complex workflows: 2. Get detailed info about a specific structure 3. Generate the structure with custom mappings 4. Validate any custom configurations +5. Lint custom or generated configurations before using them ### Integration Examples @@ -265,6 +287,17 @@ The MCP tools can be chained together for complex workflows: } ``` +**Example 3: Lint Custom Structures** +```json +{ + "name": "lint_structure", + "arguments": { + "targets": ["/path/to/custom-structure.yaml"], + "json_output": true + } +} +``` + ## Configuration ### Environment Variables @@ -354,6 +387,7 @@ Once connected, you can use these tools: - `get_structure_info` - Get details about a specific structure - `generate_structure` - Generate project structures - `validate_structure` - Validate YAML configuration files +- `lint_structure` - Lint YAML configuration files for quality and safety issues ## Troubleshooting diff --git a/structkit/commands/mcp.py b/structkit/commands/mcp.py index dc603c1..b535275 100644 --- a/structkit/commands/mcp.py +++ b/structkit/commands/mcp.py @@ -50,6 +50,7 @@ def execute(self, args): print(" - get_structure_info: Get detailed information about a structure") print(" - generate_structure: Generate structures with various options") print(" - validate_structure: Validate structure configuration files") + print(" - lint_structure: Lint structure configuration files for quality issues") print("\nExamples:") print(" structkit mcp --server --transport stdio --debug") print(" structkit mcp --server --transport http --host 127.0.0.1 --port 9000 --path /mcp --uvicorn-log-level debug") diff --git a/structkit/mcp_server.py b/structkit/mcp_server.py index e18eba4..05b0cb9 100644 --- a/structkit/mcp_server.py +++ b/structkit/mcp_server.py @@ -6,17 +6,19 @@ 2. Getting detailed information about structures 3. Generating structures with various options 4. Validating structure configurations +5. Linting structure configurations """ import asyncio import logging import os import sys import yaml -from typing import Any, Dict, Optional +from typing import Any, Dict, List, Optional from fastmcp import FastMCP from structkit.commands.generate import GenerateCommand +from structkit.commands.lint import LintCommand from structkit.commands.validate import ValidateCommand from structkit import __version__ @@ -193,6 +195,43 @@ class Args: finally: sys.stdout = old + def _lint_structure_logic( + self, + targets: Optional[List[str]] = None, + structures_path: Optional[str] = None, + lint_all: bool = False, + json_output: bool = False, + ) -> str: + class Args: + pass + + args = Args() + args.targets = targets or [] + args.structures_path = structures_path + args.all = lint_all + args.json = json_output + args.log = "INFO" + args.config_file = None + args.log_file = None + + from io import StringIO + buf = StringIO() + old = sys.stdout + sys.stdout = buf + try: + import argparse + dummy_parser = argparse.ArgumentParser() + try: + LintCommand(dummy_parser).execute(args) + except SystemExit: + # LintCommand uses SystemExit(1) to signal lint errors to the CLI. + # MCP clients need the report text instead of a transport-level exit. + pass + text = buf.getvalue() + return text.strip() or "✅ No lint issues found." + finally: + sys.stdout = old + # ===================== # FastMCP tool registration (maps to logic above) # ===================== @@ -255,6 +294,27 @@ async def validate_structure(yaml_file: str) -> str: self.logger.debug(f"MCP response: validate_structure len={len(result)} preview=\n{preview}") return result + @self.app.tool(name="lint_structure", description="Lint one or more structure configuration YAML files") + async def lint_structure( + targets: Optional[List[str]] = None, + structures_path: Optional[str] = None, + lint_all: bool = False, + json_output: bool = False, + ) -> str: + self.logger.debug( + "MCP request: lint_structure args=%s", + { + "targets": targets, + "structures_path": structures_path, + "lint_all": lint_all, + "json_output": json_output, + }, + ) + result = self._lint_structure_logic(targets, structures_path, lint_all, json_output) + preview = result if len(result) <= 1000 else result[:1000] + f"... [truncated {len(result)-1000} chars]" + self.logger.debug(f"MCP response: lint_structure len={len(result)} preview=\n{preview}") + return result + async def run( self, transport: str = "stdio", diff --git a/tests/test_mcp_integration.py b/tests/test_mcp_integration.py index c3d0c7b..4e781b1 100644 --- a/tests/test_mcp_integration.py +++ b/tests/test_mcp_integration.py @@ -1,9 +1,11 @@ """ Tests for MCP (Model Context Protocol) integration with FastMCP stdio transport. """ +import json import os import tempfile import unittest +from unittest.mock import patch import yaml from structkit.mcp_server import StructMCPServer @@ -62,6 +64,26 @@ def test_validate_structure_logic(self): finally: os.unlink(f.name) + def test_lint_structure_logic(self): + # Missing target returns the lint command's machine-readable error report. + missing = self.server._lint_structure_logic(json_output=True) + missing_payload = json.loads(missing) + self.assertEqual(missing_payload['summary']['errors'], 1) + + with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f: + yaml.dump({ + 'description': 'Lintable structure', + 'files': [ + {'README.md': {'content': 'Hello World'}} + ], + }, f) + f.flush() + try: + text = self.server._lint_structure_logic(targets=[f.name]) + self.assertIn('No lint issues found', text) + finally: + os.unlink(f.name) + class TestMCPCommands(unittest.TestCase): """Test MCP command line integration.""" @@ -97,6 +119,20 @@ def test_info_command_mcp_option(self): self.assertTrue(hasattr(args, 'mcp')) self.assertEqual(args.structure_definition, 'test_structure') + def test_mcp_command_lists_lint_tool(self): + from structkit.commands.mcp import MCPCommand + import argparse + + parser = argparse.ArgumentParser() + command = MCPCommand(parser) + args = parser.parse_args([]) + + with patch('builtins.print') as mock_print: + command.execute(args) + + printed = '\n'.join(str(call.args[0]) for call in mock_print.call_args_list) + self.assertIn('lint_structure', printed) + if __name__ == '__main__': unittest.main()