diff --git a/pyproject.toml b/pyproject.toml index dc14f93..00595da 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,6 +16,9 @@ dependencies = [ ] [project.optional-dependencies] +lsp = [ + "pygls>=1.0.0", +] dev = [ "pytest>=7.0.0", "pytest-cov>=4.0.0", diff --git a/shellmcp/__init__.py b/shellmcp/__init__.py index 75aaddb..9b461ee 100644 --- a/shellmcp/__init__.py +++ b/shellmcp/__init__.py @@ -1,3 +1,11 @@ """ShellMCP - Expose Shell Commands as MCP tools.""" -__version__ = "0.1.0" \ No newline at end of file +__version__ = "0.1.0" + +# Import LSP components for easy access (optional) +try: + from .lsp.server import create_server + __all__ = ["create_server"] +except ImportError: + # LSP dependencies not available - install with: pip install shellmcp[lsp] + __all__ = [] \ No newline at end of file diff --git a/shellmcp/cli.py b/shellmcp/cli.py index 40d763c..0707e3d 100644 --- a/shellmcp/cli.py +++ b/shellmcp/cli.py @@ -144,9 +144,46 @@ def generate(config_file: str, output_dir: str = None, verbose: bool = False) -> return 1 +def lsp(log_level: str = "INFO") -> int: + """ + Start the LSP server for shellmcp YAML files. + + Args: + log_level: Logging level (DEBUG, INFO, WARNING, ERROR) + + Returns: + Exit code (0 for success, 1 for failure) + """ + try: + from .lsp.server import create_server + import logging + + # Configure logging + logging.basicConfig(level=getattr(logging, log_level.upper())) + + # Create and start server + server = create_server() + server.start_io() + + return 0 + + except ImportError as e: + print("❌ LSP dependencies not installed.", file=sys.stderr) + print("", file=sys.stderr) + print("To install LSP support, run:", file=sys.stderr) + print(" pip install shellmcp[lsp]", file=sys.stderr) + print("", file=sys.stderr) + print("This will install the required pygls dependency for LSP functionality.", file=sys.stderr) + return 1 + except Exception as e: + print(f"❌ Error starting LSP server: {e}", file=sys.stderr) + return 1 + + def main(): """Main CLI entry point using Fire.""" fire.Fire({ 'validate': validate, - 'generate': generate + 'generate': generate, + 'lsp': lsp }) \ No newline at end of file diff --git a/shellmcp/lsp/README.md b/shellmcp/lsp/README.md new file mode 100644 index 0000000..5898392 --- /dev/null +++ b/shellmcp/lsp/README.md @@ -0,0 +1,83 @@ +# ShellMCP LSP Server + +LSP server providing autocomplete for shellmcp YAML configuration files. + +## Features + +- **Autocomplete**: Simple autocomplete for shellmcp YAML keys and values +- **Type Completions**: Built-in type suggestions (string, number, boolean, array) +- **YAML Keywords**: Basic YAML keyword completions (true, false, null) + +## Installation + +### Basic Installation +```bash +pip install shellmcp +``` + +### With LSP Support +```bash +pip install shellmcp[lsp] +``` + +## Usage + +### Run LSP Server + +```bash +shellmcp lsp +``` + +**Note**: If you get an import error, make sure you installed with LSP support: +```bash +pip install shellmcp[lsp] +``` + +### VS Code Configuration + +The LSP server provides autocomplete without requiring schema configuration, but you can still add YAML schema support if desired. + +## Autocomplete Features + +### YAML Keys +- `server` - Server configuration +- `tools` - Tool definitions +- `resources` - Resource definitions +- `prompts` - Prompt definitions +- `args` - Reusable argument definitions + +### Properties +- `name`, `desc`, `version`, `env` - Server properties +- `cmd`, `help-cmd`, `args` - Tool properties +- `uri`, `mime_type`, `file`, `text` - Resource properties +- `template` - Prompt properties +- `help`, `type`, `default`, `choices`, `pattern`, `ref` - Argument properties + +### Types +- `string` - Text value +- `number` - Numeric value +- `boolean` - True/false value +- `array` - List of values + +### YAML Keywords +- `true` - YAML true value +- `false` - YAML false value +- `null` - YAML null value + +## Example + +```yaml +server: + name: my-server + desc: My MCP server + +tools: + Hello: + cmd: echo "Hello World" + desc: Say hello + args: + - name: name + help: Name to greet + type: string + default: "World" +``` \ No newline at end of file diff --git a/shellmcp/lsp/__init__.py b/shellmcp/lsp/__init__.py new file mode 100644 index 0000000..6230839 --- /dev/null +++ b/shellmcp/lsp/__init__.py @@ -0,0 +1,3 @@ +"""LSP server for shellmcp YAML schema validation and completion.""" + +__version__ = "0.1.0" \ No newline at end of file diff --git a/shellmcp/lsp/server.py b/shellmcp/lsp/server.py new file mode 100644 index 0000000..f65bdfe --- /dev/null +++ b/shellmcp/lsp/server.py @@ -0,0 +1,244 @@ +"""LSP server focused on simple autocomplete for shellmcp YAML files.""" + +import logging +from typing import List + +from pygls.lsp.methods import ( + COMPLETION, + INITIALIZE, +) +from pygls.lsp.types import ( + CompletionItem, + CompletionItemKind, + CompletionList, + CompletionParams, + InitializeParams, +) +from pygls.server import LanguageServer + +# Configure logging +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +# Create server instance +server = LanguageServer("shellmcp-lsp", "0.1.0") + +# ShellMCP completions - focused on simple values +COMPLETIONS = { + # Root level keys + "server": "Server configuration", + "tools": "Tool definitions", + "resources": "Resource definitions", + "prompts": "Prompt definitions", + "args": "Reusable argument definitions", + + # Server properties + "name": "Server name", + "desc": "Server description", + "version": "Server version", + "env": "Environment variables", + + # Tool properties + "cmd": "Shell command", + "help-cmd": "Command to get help text", + "args": "Tool arguments", + + # Resource properties + "uri": "Resource URI", + "mime_type": "MIME type", + "file": "File path", + "text": "Direct text content", + + # Prompt properties + "template": "Template content", + + # Argument properties + "help": "Help text", + "type": "Argument type", + "default": "Default value", + "choices": "Allowed values", + "pattern": "Regex pattern", + "ref": "Reference to reusable argument", + + # Simple types + "string": "Text value", + "number": "Numeric value", + "boolean": "True/false value", + "array": "List of values", + + # YAML keywords + "true": "YAML true", + "false": "YAML false", + "null": "YAML null", +} + +# Completion kind mapping +COMPLETION_KINDS = { + # Root level keys + "server": CompletionItemKind.Module, + "tools": CompletionItemKind.Module, + "resources": CompletionItemKind.Module, + "prompts": CompletionItemKind.Module, + "args": CompletionItemKind.Module, + + # Properties + "name": CompletionItemKind.Property, + "desc": CompletionItemKind.Property, + "version": CompletionItemKind.Property, + "env": CompletionItemKind.Property, + "cmd": CompletionItemKind.Property, + "help-cmd": CompletionItemKind.Property, + "args": CompletionItemKind.Property, + "uri": CompletionItemKind.Property, + "mime_type": CompletionItemKind.Property, + "file": CompletionItemKind.Property, + "text": CompletionItemKind.Property, + "template": CompletionItemKind.Property, + "help": CompletionItemKind.Property, + "type": CompletionItemKind.Property, + "default": CompletionItemKind.Property, + "choices": CompletionItemKind.Property, + "pattern": CompletionItemKind.Property, + "ref": CompletionItemKind.Property, + + # Types + "string": CompletionItemKind.EnumMember, + "number": CompletionItemKind.EnumMember, + "boolean": CompletionItemKind.EnumMember, + "array": CompletionItemKind.EnumMember, + + # YAML keywords + "true": CompletionItemKind.Keyword, + "false": CompletionItemKind.Keyword, + "null": CompletionItemKind.Keyword, +} + + +@server.feature(INITIALIZE) +def initialize(params: InitializeParams): + """Initialize the language server.""" + return { + "capabilities": { + "completionProvider": { + "resolveProvider": False, + "triggerCharacters": [":", " "] + } + } + } + + +@server.feature(COMPLETION) +def completion(params: CompletionParams) -> CompletionList: + """Provide context-aware autocomplete suggestions.""" + try: + # Get the document and cursor position + doc = server.workspace.get_document(params.textDocument.uri) + line = doc.lines[params.position.line] + char_pos = params.position.character + + # Get the current line up to cursor + current_line = line[:char_pos] + + # Determine completion context + context = _get_completion_context(current_line, doc, params.position.line) + + # Get appropriate completions based on context + completions = _get_context_completions(context) + + return CompletionList(is_incomplete=False, items=completions) + + except Exception as e: + logger.error(f"Error in completion: {e}") + return CompletionList(is_incomplete=False, items=[]) + + +def _get_completion_context(current_line: str, doc, line_num: int) -> str: + """Determine the completion context based on current position.""" + # Check if we're completing a key (before colon) + if ":" not in current_line or current_line.strip().endswith(":"): + return "key" + + # Check if we're completing a value (after colon) + if ":" in current_line and not current_line.strip().endswith(":"): + # Look at the key to determine what type of value + key_part = current_line.split(":")[0].strip() + + # Check for type-specific completions + if key_part == "type": + return "type_value" + elif key_part in ["true", "false"]: + return "boolean_value" + else: + return "value" + + return "general" + + +def _get_context_completions(context: str) -> List[CompletionItem]: + """Get completions based on context.""" + completions = [] + + if context == "key": + # Show all possible keys + for key, detail in COMPLETIONS.items(): + kind = COMPLETION_KINDS.get(key, CompletionItemKind.Keyword) + completions.append(CompletionItem( + label=key, + kind=kind, + detail=detail + )) + + elif context == "type_value": + # Show only type values + type_completions = ["string", "number", "boolean", "array"] + for key in type_completions: + if key in COMPLETIONS: + completions.append(CompletionItem( + label=key, + kind=CompletionItemKind.EnumMember, + detail=COMPLETIONS[key] + )) + + elif context == "boolean_value": + # Show boolean values + boolean_completions = ["true", "false"] + for key in boolean_completions: + if key in COMPLETIONS: + completions.append(CompletionItem( + label=key, + kind=CompletionItemKind.Keyword, + detail=COMPLETIONS[key] + )) + + elif context == "value": + # Show YAML keywords and common values + value_completions = ["true", "false", "null"] + for key in value_completions: + if key in COMPLETIONS: + completions.append(CompletionItem( + label=key, + kind=CompletionItemKind.Keyword, + detail=COMPLETIONS[key] + )) + + else: + # Default: show all completions + for key, detail in COMPLETIONS.items(): + kind = COMPLETION_KINDS.get(key, CompletionItemKind.Keyword) + completions.append(CompletionItem( + label=key, + kind=kind, + detail=detail + )) + + return completions + + +def create_server() -> LanguageServer: + """Create and return the language server instance.""" + return server + + +if __name__ == "__main__": + # Run the server + server.start_io() \ No newline at end of file