From cba1d3fc5894a0d770764af460252ce283b799cc Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Nov 2025 17:45:18 +0000 Subject: [PATCH 1/4] feat: Add pure FastMCP server package for maritime analysis tools Create new apps/tools-mcp package with clean FastMCP implementation: - Pure FastMCP server using the FastMCP framework for MCP protocol - Dynamic tool discovery and registration from tools directory - 9 maritime analysis tools copied from tool-vault-packager: - Feature management (delete, toggle color) - Selection tools (by time, viewport, fit-to) - Track analysis (speed filtering) - Viewport utilities (grid generation) - Text utilities (word count) - Type-safe with full Pydantic validation - Automatic schema generation from Pydantic models - DebriefCommand return types for plot manipulation - Python wheel build pipeline with setuptools - Comprehensive documentation (README.md, CLAUDE.md) - Test script for validation Architecture: - server.py: FastMCP server with tool discovery - tools/: Maritime tool implementations - Build system: Makefile + pyproject.toml - Dependencies: FastMCP, Pydantic, shared-types Resolves #236 --- apps/tools-mcp/.gitignore | 37 ++ apps/tools-mcp/CLAUDE.md | 355 ++++++++++++++++++ apps/tools-mcp/Makefile | 43 +++ apps/tools-mcp/README.md | 295 +++++++++++++++ apps/tools-mcp/build_pyz.py | 78 ++++ apps/tools-mcp/pyproject.toml | 76 ++++ apps/tools-mcp/requirements-dev.txt | 9 + apps/tools-mcp/requirements.txt | 12 + apps/tools-mcp/src/tools_mcp/__init__.py | 3 + apps/tools-mcp/src/tools_mcp/__main__.py | 6 + apps/tools-mcp/src/tools_mcp/py.typed | 0 apps/tools-mcp/src/tools_mcp/server.py | 164 ++++++++ .../tools-mcp/src/tools_mcp/tools/__init__.py | 6 + .../delete_features/execute.py | 54 +++ .../samples/empty_selection.json | 7 + .../samples/multiple_features.json | 44 +++ .../toggle_first_feature_color/execute.py | 158 ++++++++ .../samples/empty_feature_collection.json | 10 + .../samples/multi_feature_collection.json | 62 +++ .../samples/simple_feature_collection.json | 47 +++ .../selection/fit_to_selection/execute.py | 112 ++++++ .../samples/mixed_features.json | 81 ++++ .../selection/select_all_visible/execute.py | 169 +++++++++ .../samples/mixed_tracks_and_points.json | 95 +++++ .../samples/polygon_zone_with_point.json | 69 ++++ .../select_feature_start_time/execute.py | 101 +++++ .../samples/sample_tracks.json | 71 ++++ .../tools/text/word_count/execute.py | 54 +++ .../text/word_count/samples/empty_text.json | 7 + .../word_count/samples/paragraph_text.json | 7 + .../text/word_count/samples/simple_text.json | 7 + .../track_speed_filter/execute.py | 181 +++++++++ .../samples/sample_track_high_speed.json | 43 +++ .../samples/sample_track_low_speed.json | 43 +++ .../track_speed_filter_fast/execute.py | 207 ++++++++++ .../samples/track_with_speeds_high.json | 49 +++ .../samples/track_with_speeds_low.json | 43 +++ .../viewport_grid_generator/execute.py | 134 +++++++ .../samples/london_area_grid.json | 16 + .../samples/small_grid.json | 16 + apps/tools-mcp/test_server.py | 48 +++ 41 files changed, 3019 insertions(+) create mode 100644 apps/tools-mcp/.gitignore create mode 100644 apps/tools-mcp/CLAUDE.md create mode 100644 apps/tools-mcp/Makefile create mode 100644 apps/tools-mcp/README.md create mode 100644 apps/tools-mcp/build_pyz.py create mode 100644 apps/tools-mcp/pyproject.toml create mode 100644 apps/tools-mcp/requirements-dev.txt create mode 100644 apps/tools-mcp/requirements.txt create mode 100644 apps/tools-mcp/src/tools_mcp/__init__.py create mode 100644 apps/tools-mcp/src/tools_mcp/__main__.py create mode 100644 apps/tools-mcp/src/tools_mcp/py.typed create mode 100644 apps/tools-mcp/src/tools_mcp/server.py create mode 100644 apps/tools-mcp/src/tools_mcp/tools/__init__.py create mode 100644 apps/tools-mcp/src/tools_mcp/tools/feature-management/delete_features/execute.py create mode 100644 apps/tools-mcp/src/tools_mcp/tools/feature-management/delete_features/samples/empty_selection.json create mode 100644 apps/tools-mcp/src/tools_mcp/tools/feature-management/delete_features/samples/multiple_features.json create mode 100644 apps/tools-mcp/src/tools_mcp/tools/feature-management/toggle_first_feature_color/execute.py create mode 100644 apps/tools-mcp/src/tools_mcp/tools/feature-management/toggle_first_feature_color/samples/empty_feature_collection.json create mode 100644 apps/tools-mcp/src/tools_mcp/tools/feature-management/toggle_first_feature_color/samples/multi_feature_collection.json create mode 100644 apps/tools-mcp/src/tools_mcp/tools/feature-management/toggle_first_feature_color/samples/simple_feature_collection.json create mode 100644 apps/tools-mcp/src/tools_mcp/tools/selection/fit_to_selection/execute.py create mode 100644 apps/tools-mcp/src/tools_mcp/tools/selection/fit_to_selection/samples/mixed_features.json create mode 100644 apps/tools-mcp/src/tools_mcp/tools/selection/select_all_visible/execute.py create mode 100644 apps/tools-mcp/src/tools_mcp/tools/selection/select_all_visible/samples/mixed_tracks_and_points.json create mode 100644 apps/tools-mcp/src/tools_mcp/tools/selection/select_all_visible/samples/polygon_zone_with_point.json create mode 100644 apps/tools-mcp/src/tools_mcp/tools/selection/select_feature_start_time/execute.py create mode 100644 apps/tools-mcp/src/tools_mcp/tools/selection/select_feature_start_time/samples/sample_tracks.json create mode 100644 apps/tools-mcp/src/tools_mcp/tools/text/word_count/execute.py create mode 100644 apps/tools-mcp/src/tools_mcp/tools/text/word_count/samples/empty_text.json create mode 100644 apps/tools-mcp/src/tools_mcp/tools/text/word_count/samples/paragraph_text.json create mode 100644 apps/tools-mcp/src/tools_mcp/tools/text/word_count/samples/simple_text.json create mode 100644 apps/tools-mcp/src/tools_mcp/tools/track-analysis/track_speed_filter/execute.py create mode 100644 apps/tools-mcp/src/tools_mcp/tools/track-analysis/track_speed_filter/samples/sample_track_high_speed.json create mode 100644 apps/tools-mcp/src/tools_mcp/tools/track-analysis/track_speed_filter/samples/sample_track_low_speed.json create mode 100644 apps/tools-mcp/src/tools_mcp/tools/track-analysis/track_speed_filter_fast/execute.py create mode 100644 apps/tools-mcp/src/tools_mcp/tools/track-analysis/track_speed_filter_fast/samples/track_with_speeds_high.json create mode 100644 apps/tools-mcp/src/tools_mcp/tools/track-analysis/track_speed_filter_fast/samples/track_with_speeds_low.json create mode 100644 apps/tools-mcp/src/tools_mcp/tools/viewport/viewport_grid_generator/execute.py create mode 100644 apps/tools-mcp/src/tools_mcp/tools/viewport/viewport_grid_generator/samples/london_area_grid.json create mode 100644 apps/tools-mcp/src/tools_mcp/tools/viewport/viewport_grid_generator/samples/small_grid.json create mode 100644 apps/tools-mcp/test_server.py diff --git a/apps/tools-mcp/.gitignore b/apps/tools-mcp/.gitignore new file mode 100644 index 00000000..54b07684 --- /dev/null +++ b/apps/tools-mcp/.gitignore @@ -0,0 +1,37 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python + +# Virtual environments +.venv/ +venv/ +ENV/ +env/ + +# Build artifacts +dist/ +build/ +*.egg-info/ +*.egg +*.whl +*.pyz +tmp_pyz_build/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# Testing +.pytest_cache/ +.coverage +htmlcov/ + +# Distribution / packaging +.eggs/ +pip-wheel-metadata/ diff --git a/apps/tools-mcp/CLAUDE.md b/apps/tools-mcp/CLAUDE.md new file mode 100644 index 00000000..f27a94d9 --- /dev/null +++ b/apps/tools-mcp/CLAUDE.md @@ -0,0 +1,355 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with the Tools MCP package. + +## Overview + +Tools MCP is a pure FastMCP server implementation that exposes maritime analysis tools via the Model Context Protocol (MCP). It provides a clean, modern alternative to the legacy REST-based tool serving strategy. + +## Quick Start + +### Development Commands + +```bash +# Install in development mode +pip install -e . + +# Run the server +python -m tools_mcp + +# Test the server +python test_server.py + +# Build distribution +make build +``` + +### Package Structure + +``` +src/tools_mcp/ +├── __init__.py # Package initialization +├── __main__.py # CLI entry point +├── server.py # FastMCP server with tool discovery +└── tools/ # Maritime analysis tools (copied from tool-vault-packager) + ├── feature-management/ + ├── selection/ + ├── text/ + ├── track-analysis/ + └── viewport/ +``` + +## Architecture + +### FastMCP Server (server.py) + +The core server implementation: + +1. **Tool Discovery**: Automatically discovers tools in the `tools/` directory +2. **Dynamic Registration**: Registers each tool as a FastMCP tool with proper schemas +3. **Type Conversion**: Converts DebriefCommand responses to dictionaries for MCP transport +4. **Error Handling**: Catches tool execution errors and returns them as ShowTextCommand + +Key functions: +- `discover_tools()`: Scans tools directory and imports tool modules +- `register_tool()`: Wraps tool functions for FastMCP compatibility +- `initialize_server()`: Sets up the FastMCP instance with all tools + +### Tool Structure + +Each tool follows this pattern: + +```python +from pydantic import BaseModel, Field +from debrief.types.tools import DebriefCommand, ShowTextCommand + +class ToolParameters(BaseModel): + """Pydantic model for tool parameters.""" + param1: str = Field(description="Parameter description") + +def tool_function(params: ToolParameters) -> DebriefCommand: + """ + Tool description (used in MCP schema). + + Args: + params: Tool parameters + + Returns: + DebriefCommand with the result + """ + # Tool implementation + return ShowTextCommand(payload="Result") +``` + +### Tool Discovery Rules + +For a tool to be discovered: + +1. Must be in `tools/{category}/{tool_name}/execute.py` +2. Must have exactly one public function (not starting with `_`) +3. Function must have a `params` parameter with a Pydantic BaseModel type hint +4. Function must return a DebriefCommand + +## Development Workflow + +### Adding New Tools + +1. Create directory: `src/tools_mcp/tools/{category}/{tool_name}/` +2. Create `execute.py` with the tool implementation +3. Define a Pydantic parameters model +4. Implement the tool function +5. Test with `python test_server.py` +6. The tool will be automatically discovered on server startup + +### Modifying Existing Tools + +1. Tools are located in `src/tools_mcp/tools/` +2. Each tool has its own directory with `execute.py` +3. Edit the execute.py file +4. Restart the server to see changes +5. Use test_server.py to verify + +### Testing Tools + +```python +# test_server.py demonstrates how to test tools +from tools_mcp.server import mcp +from tools_mcp.tools.text.word_count.execute import WordCountParameters + +# Get the tool +tool = mcp._tool_manager._tools["text_word_count"] + +# Create test parameters +params = WordCountParameters(text="Hello world") + +# Call the tool +result = tool.fn(params) +print(result) # {'command': 'showText', 'payload': 'Word count: 2'} +``` + +## Dependencies + +### Shared Types + +Tools depend on `debrief-types` from `libs/shared-types`: + +- **DebriefCommand**: Base class for all command responses +- **Command Types**: ShowTextCommand, SetSelectionCommand, etc. +- **Feature Types**: DebriefFeature, DebriefFeatureCollection, etc. +- **State Types**: SelectionState, ViewportState, TimeState + +To update shared types: +```bash +# Rebuild shared-types +cd ../../libs/shared-types +pnpm build + +# Reinstall in tools-mcp +cd ../../apps/tools-mcp +pip install -e ../../libs/shared-types --force-reinstall +``` + +### FastMCP + +The server uses FastMCP for MCP protocol implementation: + +- Automatic schema generation from Pydantic models +- Tool registration with @mcp.tool decorator +- Built-in transport handling (stdio, HTTP, SSE) + +## Build System + +### Building the Package + +```bash +# Build Python wheel +python -m build + +# Output: dist/tools_mcp-1.0.0-py3-none-any.whl +``` + +### Build Artifacts + +- **Wheel**: `dist/tools_mcp-1.0.0-py3-none-any.whl` - Standard Python package +- **.pyz zipapp**: Not recommended due to binary dependency issues + +### Installation + +```bash +# Development mode (editable) +pip install -e . + +# From wheel +pip install dist/tools_mcp-1.0.0-py3-none-any.whl +``` + +## Tool Command Reference + +### Display Commands + +- **ShowTextCommand**: Display text message + ```python + ShowTextCommand(payload="Message text") + ``` + +- **ShowDataCommand**: Display structured data + ```python + ShowDataCommand(payload={"key": "value"}) + ``` + +- **ShowImageCommand**: Display an image + ```python + ShowImageCommand(payload=ShowImagePayload(...)) + ``` + +### State Management Commands + +- **SetSelectionCommand**: Update feature selection + ```python + SetSelectionCommand(payload=SelectionState(selectedIds=["id1", "id2"])) + ``` + +- **SetViewportCommand**: Update map viewport + ```python + SetViewportCommand(payload=ViewportState(bounds=[west, south, east, north])) + ``` + +- **SetTimeStateCommand**: Update time state + ```python + SetTimeStateCommand(payload=TimeState(currentTime=datetime.now())) + ``` + +### Feature Management Commands + +- **AddFeaturesCommand**: Add new features + ```python + AddFeaturesCommand(payload=[feature1, feature2]) + ``` + +- **UpdateFeaturesCommand**: Update existing features + ```python + UpdateFeaturesCommand(payload=[updated_feature1]) + ``` + +- **DeleteFeaturesCommand**: Delete features + ```python + DeleteFeaturesCommand(payload=["feature_id_1", "feature_id_2"]) + ``` + +- **SetFeatureCollectionCommand**: Replace entire collection + ```python + SetFeatureCollectionCommand(payload=DebriefFeatureCollection(...)) + ``` + +## Troubleshooting + +### Tool Not Discovered + +Check: +1. Directory structure: `tools/{category}/{tool_name}/execute.py` +2. Exactly one public function in execute.py +3. Function has proper type hints: `def func(params: Model) -> DebriefCommand` +4. Check server startup logs for discovery messages + +### Import Errors + +```bash +# Missing debrief.types +pip install -e ../../libs/shared-types + +# Missing fastmcp +pip install fastmcp>=0.5.0 +``` + +### Type Errors + +Ensure: +1. All tool parameters use Pydantic BaseModel +2. Function returns a DebriefCommand subclass +3. Type hints are complete and accurate + +### Runtime Errors + +Tools should handle errors gracefully: +```python +try: + # Tool logic + result = process_data() + return ShowTextCommand(payload=result) +except Exception as e: + return ShowTextCommand(payload=f"Error: {str(e)}") +``` + +## Common Patterns + +### Validation Pattern + +```python +from pydantic import BaseModel, Field, field_validator + +class Params(BaseModel): + value: int = Field(gt=0, description="Must be positive") + + @field_validator('value') + @classmethod + def check_range(cls, v): + if v > 100: + raise ValueError("Value too large") + return v +``` + +### Feature Processing Pattern + +```python +def process_features(params: Params) -> DebriefCommand: + """Process features.""" + features = params.feature_collection.features + + # Process each feature + results = [] + for feature in features: + # Process feature + results.append(process_one(feature)) + + return ShowDataCommand(payload={"results": results}) +``` + +### Error Handling Pattern + +```python +def safe_tool(params: Params) -> DebriefCommand: + """Tool with error handling.""" + try: + result = risky_operation(params) + return ShowTextCommand(payload=f"Success: {result}") + except ValueError as e: + return ShowTextCommand(payload=f"Invalid input: {e}") + except Exception as e: + return ShowTextCommand(payload=f"Error: {e}") +``` + +## Integration with VS Code Extension + +Future integration will allow: + +1. Tool execution from Python scripts via MCP +2. Direct communication with VS Code extension +3. Maritime plot manipulation from Python +4. Seamless tool testing and debugging + +## Future Development + +Planned enhancements: + +1. **Streaming Support**: Long-running tool execution with progress updates +2. **Caching**: Tool result caching for performance +3. **Metrics**: Tool execution timing and success rates +4. **Additional Tools**: More maritime analysis capabilities +5. **Multi-language**: Support for non-Python tool implementations + +## Resources + +- FastMCP Documentation: https://github.com/jlowin/fastmcp +- MCP Specification: https://spec.modelcontextprotocol.io/ +- Pydantic Documentation: https://docs.pydantic.dev/ +- Debrief Shared Types: ../../libs/shared-types/README.md diff --git a/apps/tools-mcp/Makefile b/apps/tools-mcp/Makefile new file mode 100644 index 00000000..081218f6 --- /dev/null +++ b/apps/tools-mcp/Makefile @@ -0,0 +1,43 @@ +.PHONY: help build clean install test lint typecheck + +help: + @echo "Available targets:" + @echo " build - Build Python wheel and .pyz zipapp" + @echo " clean - Remove build artifacts" + @echo " install - Install package in development mode" + @echo " test - Run tests" + @echo " lint - Run code linting" + @echo " typecheck - Run type checking" + +build: clean + @echo "Building tools-mcp package..." + @python3 -m pip install build + @python3 -m build + @echo "Creating .pyz zipapp..." + @python3 build_pyz.py + @echo "Build complete!" + @ls -lh dist/ + +clean: + @echo "Cleaning build artifacts..." + @rm -rf dist/ build/ *.egg-info + @rm -f tools-mcp.pyz + @find . -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true + @echo "Clean complete!" + +install: + @echo "Installing in development mode..." + @python3 -m pip install -e . + @echo "Install complete!" + +test: + @echo "Running tests..." + @python3 test_server.py + +lint: + @echo "Running linting..." + @ruff check src/ + +typecheck: + @echo "Running type checking..." + @mypy src/ diff --git a/apps/tools-mcp/README.md b/apps/tools-mcp/README.md new file mode 100644 index 00000000..eeb3ce87 --- /dev/null +++ b/apps/tools-mcp/README.md @@ -0,0 +1,295 @@ +# Tools MCP - Pure FastMCP Server for Maritime Analysis Tools + +A clean, modern FastMCP server implementation that exposes maritime analysis tools via the Model Context Protocol (MCP). + +## Overview + +Tools MCP is a pure FastMCP implementation that provides a lightweight, standards-compliant MCP server for maritime analysis tools. It replaces the legacy REST-based tool serving strategy with native MCP support. + +### Key Features + +- **Pure FastMCP**: Native MCP server implementation using the FastMCP framework +- **9 Maritime Tools**: Complete set of tools for maritime analysis and visualization +- **Type-Safe**: Full Pydantic validation for inputs and outputs +- **Docker-Compatible**: Standard Python wheel distribution +- **MCP Inspector Ready**: Built-in support for MCP Inspector verification + +## Architecture + +### Package Structure + +``` +apps/tools-mcp/ +├── src/ +│ └── tools_mcp/ +│ ├── __init__.py +│ ├── __main__.py +│ ├── server.py # FastMCP server implementation +│ └── tools/ # Maritime analysis tools +│ ├── feature-management/ +│ ├── selection/ +│ ├── text/ +│ ├── track-analysis/ +│ └── viewport/ +├── pyproject.toml +├── requirements.txt +├── Makefile +└── README.md +``` + +### Available Tools + +The server provides 9 maritime analysis tools: + +1. **selection_select_feature_start_time** - Select features by start time +2. **selection_fit_to_selection** - Fit viewport to selected features +3. **selection_select_all_visible** - Select all visible features in viewport +4. **track-analysis_track_speed_filter_fast** - Fast track speed filtering +5. **track-analysis_track_speed_filter** - Comprehensive track speed filtering +6. **text_word_count** - Text word counting utility +7. **viewport_viewport_grid_generator** - Generate viewport grid overlays +8. **feature-management_toggle_first_feature_color** - Toggle feature colors +9. **feature-management_delete_features** - Delete selected features + +## Installation + +### Prerequisites + +- Python 3.10 or later +- pip or uv package manager +- Access to the shared-types package + +### Development Installation + +```bash +# Clone the repository +cd apps/tools-mcp + +# Create virtual environment +python3 -m venv .venv +source .venv/bin/activate # On Windows: .venv\Scripts\activate + +# Install in development mode +pip install -e . +``` + +### Production Installation + +```bash +# Install from wheel +pip install dist/tools_mcp-1.0.0-py3-none-any.whl +``` + +## Usage + +### Running the Server + +```bash +# Using the installed command +tools-mcp + +# Or run directly with Python +python -m tools_mcp + +# Or execute the module +python -m tools_mcp.server +``` + +The server will start and register all available tools. By default, FastMCP listens on stdio for MCP communication. + +### Testing the Server + +```bash +# Run the test script +python test_server.py +``` + +### Building Distribution Packages + +```bash +# Build Python wheel +python -m build + +# Or use Make +make build +``` + +## Development + +### Project Structure + +- **server.py**: Core FastMCP server implementation with tool discovery +- **tools/**: Maritime analysis tool implementations +- **test_server.py**: Simple test script for validation + +### Adding New Tools + +1. Create a new directory under `src/tools_mcp/tools/{category}/{tool_name}/` +2. Add an `execute.py` file with: + - A Pydantic parameters model + - A single public function that takes params and returns a DebriefCommand +3. The tool will be automatically discovered and registered + +Example: + +```python +from pydantic import BaseModel, Field +from debrief.types.tools import ShowTextCommand, DebriefCommand + +class MyToolParameters(BaseModel): + """Parameters for my_tool.""" + text: str = Field(description="Input text") + +def my_tool(params: MyToolParameters) -> DebriefCommand: + """Process the input text.""" + result = params.text.upper() + return ShowTextCommand(payload=f"Result: {result}") +``` + +### Code Quality + +```bash +# Linting +make lint +# or +ruff check src/ + +# Type checking +make typecheck +# or +mypy src/ + +# Run all checks +ruff check src/ && mypy src/ +``` + +## Tool Command Types + +All tools return DebriefCommand objects that trigger actions in the maritime analysis platform: + +- **ShowTextCommand**: Display text to the user +- **ShowDataCommand**: Display structured data +- **SetSelectionCommand**: Update feature selection +- **SetViewportCommand**: Update map viewport +- **SetTimeStateCommand**: Update time state +- **AddFeaturesCommand**: Add new features to the map +- **UpdateFeaturesCommand**: Update existing features +- **DeleteFeaturesCommand**: Remove features from the map +- **SetFeatureCollectionCommand**: Replace entire feature collection +- **ShowImageCommand**: Display an image +- **LogMessageCommand**: Log a message +- **CompositeCommand**: Execute multiple commands in sequence + +## MCP Inspector Integration + +The server can be inspected and tested using the MCP Inspector tool: + +```bash +# Install MCP Inspector +pip install mcp-inspector + +# Inspect the server +mcp-inspector tools-mcp +``` + +## Dependencies + +### Runtime Dependencies + +- **fastmcp**: FastMCP framework for MCP server implementation +- **pydantic**: Data validation and settings management +- **geojson-pydantic**: GeoJSON validation support +- **jsonschema**: JSON schema validation +- **debrief-types**: Shared types from the monorepo + +### Development Dependencies + +- **ruff**: Code linting and formatting +- **mypy**: Static type checking +- **pytest**: Testing framework +- **mcp-inspector**: MCP server inspection tool + +## Deployment + +### Docker Integration + +The package is designed to work with Docker deployments. The Python wheel can be installed in a Docker container: + +```dockerfile +FROM python:3.11-slim + +# Install the wheel +COPY dist/tools_mcp-1.0.0-py3-none-any.whl /tmp/ +RUN pip install /tmp/tools_mcp-1.0.0-py3-none-any.whl + +# Run the server +CMD ["tools-mcp"] +``` + +### VS Code Extension Integration + +Future versions will integrate with the VS Code extension for seamless tool execution from Python scripts. + +## Known Limitations + +1. **Binary Dependencies**: The .pyz zipapp format is not recommended due to binary extension compatibility issues with pydantic-core and other native dependencies. Use the Python wheel distribution instead. + +2. **Tool Discovery**: Tools must follow the exact directory structure and naming conventions for automatic discovery. + +3. **Error Handling**: Tool errors are returned as ShowTextCommand objects with error messages. + +## Troubleshooting + +### Import Errors + +If you encounter import errors for `debrief.types`: + +```bash +# Ensure shared-types is installed +pip install -e ../../libs/shared-types +``` + +### Tool Not Found + +If a tool isn't being discovered: + +1. Check the directory structure matches `tools/{category}/{tool_name}/execute.py` +2. Ensure the execute.py has exactly one public function +3. Verify the function has proper type hints with a Pydantic parameter model +4. Check the function returns a DebriefCommand + +### FastMCP Connection Issues + +For connection issues: + +1. Verify FastMCP is installed: `pip show fastmcp` +2. Check Python version is 3.10+ +3. Review FastMCP logs for detailed error messages + +## Contributing + +When contributing new tools or features: + +1. Follow the existing code structure +2. Add proper type hints and docstrings +3. Include parameter validation with Pydantic +4. Test with the test_server.py script +5. Update documentation as needed + +## License + +MIT License - See repository root for details. + +## Related Packages + +- **libs/shared-types**: Shared type definitions and validators +- **libs/tool-vault-packager**: Legacy REST-based tool serving (deprecated) +- **apps/vs-code**: VS Code extension for maritime analysis + +## Future Enhancements + +- [ ] VS Code extension integration +- [ ] Additional maritime analysis tools +- [ ] Streaming tool execution support +- [ ] Tool execution metrics and logging +- [ ] Multi-language tool support diff --git a/apps/tools-mcp/build_pyz.py b/apps/tools-mcp/build_pyz.py new file mode 100644 index 00000000..ac6c026b --- /dev/null +++ b/apps/tools-mcp/build_pyz.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 +"""Build a .pyz zipapp for tools-mcp.""" + +import shutil +import subprocess +import sys +import zipapp +from pathlib import Path + + +def build_pyz(): + """Build a .pyz zipapp containing the tools-mcp server.""" + print("Building tools-mcp.pyz...") + + # Temporary directory for building the zipapp + tmp_dir = Path("tmp_pyz_build") + if tmp_dir.exists(): + shutil.rmtree(tmp_dir) + tmp_dir.mkdir() + + try: + # Install dependencies into tmp_dir + print("Installing dependencies...") + subprocess.check_call([ + sys.executable, "-m", "pip", "install", + "--target", str(tmp_dir), + "--upgrade", + "fastmcp>=0.5.0", + "pydantic>=2.0.0", + "geojson-pydantic>=1.0.0", + "jsonschema>=4.17.0", + "../../libs/shared-types", # Install shared-types from local path + ]) + + # Copy source files + print("Copying source files...") + src_dir = Path("src/tools_mcp") + dest_dir = tmp_dir / "tools_mcp" + shutil.copytree(src_dir, dest_dir) + + # Create __main__.py for zipapp entry point + main_content = """ +import sys +from tools_mcp.server import main + +if __name__ == "__main__": + main() +""" + (tmp_dir / "__main__.py").write_text(main_content) + + # Create the zipapp + print("Creating zipapp...") + output_file = Path("tools-mcp.pyz") + zipapp.create_archive( + source=tmp_dir, + target=output_file, + interpreter="/usr/bin/env python3", + compressed=True, + ) + + print(f"✓ Created {output_file} ({output_file.stat().st_size / 1024 / 1024:.2f} MB)") + return True + + except Exception as e: + print(f"Error building .pyz: {e}") + import traceback + traceback.print_exc() + return False + + finally: + # Cleanup + if tmp_dir.exists(): + shutil.rmtree(tmp_dir) + + +if __name__ == "__main__": + success = build_pyz() + sys.exit(0 if success else 1) diff --git a/apps/tools-mcp/pyproject.toml b/apps/tools-mcp/pyproject.toml new file mode 100644 index 00000000..a7ddc22b --- /dev/null +++ b/apps/tools-mcp/pyproject.toml @@ -0,0 +1,76 @@ +[build-system] +requires = ["setuptools>=70.0.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "tools-mcp" +version = "1.0.0" +description = "Pure FastMCP server for maritime analysis tools" +authors = [{name = "Future Debrief Team"}] +license = {text = "MIT"} +readme = "README.md" +requires-python = ">=3.10" +dependencies = [ + "fastmcp>=0.5.0", + "pydantic>=2.0.0", +] + +[project.optional-dependencies] +dev = [ + "ruff>=0.1.0", + "mypy>=1.0.0", + "pytest>=7.0.0", + "mcp-inspector>=0.1.0", +] + +[project.scripts] +tools-mcp = "tools_mcp.server:main" + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.setuptools.package-data] +"*" = ["py.typed"] + +[tool.mypy] +python_version = "3.10" +warn_return_any = false +disallow_untyped_defs = false +ignore_missing_imports = true +show_error_codes = true +warn_unused_configs = true +exclude = [ + "dist/", + "__pycache__/", + ".pytest_cache/", +] + +[tool.ruff] +target-version = "py310" +line-length = 100 +exclude = [ + "dist/", + "__pycache__/", + ".pytest_cache/", + ".git", +] + +[tool.ruff.lint] +select = [ + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # pyflakes + "I", # isort +] +ignore = [ + "E501", # line too long (handled by formatter) +] + +[tool.ruff.format] +quote-style = "double" +indent-style = "space" +skip-magic-trailing-comma = false +line-ending = "auto" + +[tool.ruff.lint.isort] +known-first-party = ["tools_mcp"] diff --git a/apps/tools-mcp/requirements-dev.txt b/apps/tools-mcp/requirements-dev.txt new file mode 100644 index 00000000..2aa78f0f --- /dev/null +++ b/apps/tools-mcp/requirements-dev.txt @@ -0,0 +1,9 @@ +-r requirements.txt + +# Development tools +ruff>=0.1.0 +mypy>=1.0.0 +pytest>=7.0.0 + +# MCP Inspector for testing +mcp-inspector>=0.1.0 diff --git a/apps/tools-mcp/requirements.txt b/apps/tools-mcp/requirements.txt new file mode 100644 index 00000000..329c8667 --- /dev/null +++ b/apps/tools-mcp/requirements.txt @@ -0,0 +1,12 @@ +# Core FastMCP dependencies +fastmcp>=0.5.0 +pydantic>=2.0.0 + +# GeoJSON validation for shared types +geojson-pydantic>=1.0.0 + +# JSON schema validation +jsonschema>=4.17.0 + +# Shared types from monorepo +../../libs/shared-types diff --git a/apps/tools-mcp/src/tools_mcp/__init__.py b/apps/tools-mcp/src/tools_mcp/__init__.py new file mode 100644 index 00000000..c0e3ac2b --- /dev/null +++ b/apps/tools-mcp/src/tools_mcp/__init__.py @@ -0,0 +1,3 @@ +"""Tools MCP - Pure FastMCP server for maritime analysis tools.""" + +__version__ = "1.0.0" diff --git a/apps/tools-mcp/src/tools_mcp/__main__.py b/apps/tools-mcp/src/tools_mcp/__main__.py new file mode 100644 index 00000000..4c575d0f --- /dev/null +++ b/apps/tools-mcp/src/tools_mcp/__main__.py @@ -0,0 +1,6 @@ +"""CLI entry point for tools-mcp server.""" + +from .server import main + +if __name__ == "__main__": + main() diff --git a/apps/tools-mcp/src/tools_mcp/py.typed b/apps/tools-mcp/src/tools_mcp/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/apps/tools-mcp/src/tools_mcp/server.py b/apps/tools-mcp/src/tools_mcp/server.py new file mode 100644 index 00000000..a27e66f2 --- /dev/null +++ b/apps/tools-mcp/src/tools_mcp/server.py @@ -0,0 +1,164 @@ +"""FastMCP server for maritime analysis tools.""" + +import importlib +import inspect +from pathlib import Path +from typing import Any, Callable, Dict, get_type_hints + +from fastmcp import FastMCP +from pydantic import BaseModel + +from debrief.types.tools import DebriefCommand + +# Initialize FastMCP server +mcp = FastMCP("Maritime Analysis Tools 🚢") + + +def discover_tools(tools_dir: Path) -> Dict[str, tuple[Callable, BaseModel]]: + """ + Discover all tools in the tools directory. + + Each tool is expected to be in a subdirectory with an execute.py file + containing a single public function that takes a Pydantic parameters model + and returns a DebriefCommand. + + Args: + tools_dir: Path to the tools directory + + Returns: + Dictionary mapping tool names to (function, parameter_model) tuples + """ + tools = {} + + # Iterate through all subdirectories + for category_dir in tools_dir.iterdir(): + if not category_dir.is_dir() or category_dir.name.startswith("_"): + continue + + # Iterate through tool directories within each category + for tool_dir in category_dir.iterdir(): + if not tool_dir.is_dir() or tool_dir.name.startswith("_"): + continue + + execute_file = tool_dir / "execute.py" + if not execute_file.exists(): + continue + + # Import the execute module + module_path = f"tools_mcp.tools.{category_dir.name}.{tool_dir.name}.execute" + try: + module = importlib.import_module(module_path) + + # Find the public function (not starting with _) + public_functions = [ + (name, obj) for name, obj in inspect.getmembers(module, inspect.isfunction) + if not name.startswith("_") and obj.__module__ == module.__name__ + ] + + if len(public_functions) != 1: + print(f"Warning: {module_path} should have exactly one public function, found {len(public_functions)}") + continue + + func_name, func = public_functions[0] + + # Get the parameter type from type hints + type_hints = get_type_hints(func) + if "params" not in type_hints: + print(f"Warning: {func_name} should have a 'params' parameter") + continue + + param_type = type_hints["params"] + if not issubclass(param_type, BaseModel): + print(f"Warning: {func_name} params should be a Pydantic BaseModel") + continue + + # Store the tool with a unique name + tool_name = f"{category_dir.name}_{tool_dir.name}" + tools[tool_name] = (func, param_type) + print(f"Discovered tool: {tool_name}") + + except Exception as e: + print(f"Error loading tool {tool_dir}: {e}") + + return tools + + +def register_tool(name: str, func: Callable, param_model: type[BaseModel]) -> None: + """ + Register a tool with FastMCP. + + Args: + name: Name of the tool + func: Tool function + param_model: Pydantic parameter model class + """ + # Extract docstring from the original function + doc = inspect.getdoc(func) or "No description available" + + # Create a wrapper function that takes a single parameter model argument + # This is the simplest approach that works with FastMCP + def tool_wrapper(params: param_model) -> Dict[str, Any]: # type: ignore[valid-type] + """Wrapper function for FastMCP tool execution.""" + try: + # Call the tool function with the parameter model + result = func(params) + + # Convert DebriefCommand to dict + if isinstance(result, DebriefCommand): + return result.model_dump() + elif isinstance(result, dict): + return result + else: + return {"command": "showText", "payload": str(result)} + + except Exception as e: + # Return error as showText command + import traceback + return { + "command": "showText", + "payload": f"Error executing {name}: {str(e)}\n{traceback.format_exc()}" + } + + # Update wrapper metadata + tool_wrapper.__name__ = name + tool_wrapper.__doc__ = doc + + # Register with FastMCP + mcp.tool(tool_wrapper) + + +def initialize_server() -> FastMCP: + """ + Initialize the FastMCP server with all discovered tools. + + Returns: + Configured FastMCP instance + """ + # Find tools directory + tools_dir = Path(__file__).parent / "tools" + + if not tools_dir.exists(): + print(f"Warning: Tools directory not found at {tools_dir}") + return mcp + + # Discover and register tools + tools = discover_tools(tools_dir) + print(f"Found {len(tools)} tools") + + for tool_name, (func, param_model) in tools.items(): + register_tool(tool_name, func, param_model) + + return mcp + + +# Initialize server on module import +server = initialize_server() + + +def main() -> None: + """Run the FastMCP server.""" + server.run() + + +if __name__ == "__main__": + main() diff --git a/apps/tools-mcp/src/tools_mcp/tools/__init__.py b/apps/tools-mcp/src/tools_mcp/tools/__init__.py new file mode 100644 index 00000000..e420e6a8 --- /dev/null +++ b/apps/tools-mcp/src/tools_mcp/tools/__init__.py @@ -0,0 +1,6 @@ +"""Tools package for ToolVault packager. + +This package contains folders of tool directories, each with: +- execute.py: The tool implementation with exactly one public function +- inputs/: Sample input JSON files for testing and demonstration +""" diff --git a/apps/tools-mcp/src/tools_mcp/tools/feature-management/delete_features/execute.py b/apps/tools-mcp/src/tools_mcp/tools/feature-management/delete_features/execute.py new file mode 100644 index 00000000..f2f58e63 --- /dev/null +++ b/apps/tools-mcp/src/tools_mcp/tools/feature-management/delete_features/execute.py @@ -0,0 +1,54 @@ +"""Delete selected features from a FeatureCollection.""" + +from typing import List + +# Use hierarchical imports from shared-types +from debrief.types.features import DebriefFeature +from debrief.types.tools import DeleteFeaturesCommand +from pydantic import BaseModel, Field + + +class DeleteFeaturesParameters(BaseModel): + """Parameters for delete_features tool.""" + + features: List[DebriefFeature] = Field( + description="Array of Debrief features to delete", + examples=[ + [ + { + "type": "Feature", + "id": "track-001", + "geometry": {"type": "LineString", "coordinates": [[0, 0], [1, 1]]}, + "properties": {"dataType": "track"}, + } + ] + ], + ) + + +def delete_features(params: DeleteFeaturesParameters) -> DeleteFeaturesCommand: + """ + Delete selected features from the current plot. + + Extracts feature IDs from the selected features and returns a command + to delete them from the FeatureCollection. + + Args: + params: DeleteFeaturesParameters with features to delete + + Returns: + DeleteFeaturesCommand with list of feature IDs to delete + + Examples: + >>> params = DeleteFeaturesParameters(features=[{"type": "Feature", "id": "feature-1", ...}]) + >>> result = delete_features(params) + >>> result.command + 'deleteFeatures' + >>> result.payload + ['feature-1'] + """ + # Extract feature IDs from input features and convert to strings + feature_ids = [str(f.id) for f in params.features if f.id is not None] + + # Return DeleteFeaturesCommand + return DeleteFeaturesCommand(payload=feature_ids) diff --git a/apps/tools-mcp/src/tools_mcp/tools/feature-management/delete_features/samples/empty_selection.json b/apps/tools-mcp/src/tools_mcp/tools/feature-management/delete_features/samples/empty_selection.json new file mode 100644 index 00000000..946f80e5 --- /dev/null +++ b/apps/tools-mcp/src/tools_mcp/tools/feature-management/delete_features/samples/empty_selection.json @@ -0,0 +1,7 @@ +{ + "input": { + "features": [] + }, + "expectedOutput": null, + "baseline_error": "Object of type DeleteFeaturesCommand is not JSON serializable" +} \ No newline at end of file diff --git a/apps/tools-mcp/src/tools_mcp/tools/feature-management/delete_features/samples/multiple_features.json b/apps/tools-mcp/src/tools_mcp/tools/feature-management/delete_features/samples/multiple_features.json new file mode 100644 index 00000000..4723a3f6 --- /dev/null +++ b/apps/tools-mcp/src/tools_mcp/tools/feature-management/delete_features/samples/multiple_features.json @@ -0,0 +1,44 @@ +{ + "input": { + "features": [ + { + "type": "Feature", + "id": "track-001", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 0, + 0 + ], + [ + 1, + 1 + ] + ] + }, + "properties": { + "dataType": "track", + "name": "Track 1" + } + }, + { + "type": "Feature", + "id": "point-001", + "geometry": { + "type": "Point", + "coordinates": [ + 0.5, + 0.5 + ] + }, + "properties": { + "dataType": "reference-point", + "name": "Point 1" + } + } + ] + }, + "expectedOutput": null, + "baseline_error": "Object of type DeleteFeaturesCommand is not JSON serializable" +} \ No newline at end of file diff --git a/apps/tools-mcp/src/tools_mcp/tools/feature-management/toggle_first_feature_color/execute.py b/apps/tools-mcp/src/tools_mcp/tools/feature-management/toggle_first_feature_color/execute.py new file mode 100644 index 00000000..d703a72c --- /dev/null +++ b/apps/tools-mcp/src/tools_mcp/tools/feature-management/toggle_first_feature_color/execute.py @@ -0,0 +1,158 @@ +"""Feature color toggling tool for GeoJSON FeatureCollections.""" + +from debrief.types.features import DebriefFeatureCollection +from debrief.types.tools import ( + DebriefCommand, + ShowTextCommand, + UpdateFeaturesCommand, +) +from pydantic import BaseModel, Field, ValidationError + + +class ToggleFirstFeatureColorParameters(BaseModel): + """Parameters for the toggle_first_feature_color tool.""" + + feature_collection: DebriefFeatureCollection = Field( + description="A GeoJSON FeatureCollection object conforming to the Debrief FeatureCollection schema", + examples=[ + { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "id": "feature_001", + "properties": {"color": "red", "dataType": "point"}, + "geometry": {"type": "Point", "coordinates": [0, 0]}, + } + ], + } + ], + ) + + +def toggle_first_feature_color(params: ToggleFirstFeatureColorParameters) -> DebriefCommand: + """ + Toggle the color property of the first feature in a GeoJSON FeatureCollection. + + This function modifies the first feature in the provided FeatureCollection by + toggling its color property between 'red' and 'blue'. If the first feature + doesn't have a color property, it will be set to 'red'. If the collection + is empty, it returns an appropriate message. Returns a ToolVault command to + update only the modified feature. + + Args: + params: ToggleFirstFeatureColorParameters containing feature_collection + + Returns: + DebriefCommand: Command to update the modified feature (not replace entire collection) + + Examples: + >>> from pydantic import ValidationError + >>> params = ToggleFirstFeatureColorParameters( + ... feature_collection={ + ... "type": "FeatureCollection", + ... "features": [{ + ... "type": "Feature", + ... "id": "feature1", + ... "properties": {"color": "red", "dataType": "point"}, + ... "geometry": {"type": "Point", "coordinates": [0, 0]} + ... }] + ... } + ... ) + >>> result = toggle_first_feature_color(params) + >>> result.command + 'updateFeatures' + """ + try: + # Work with the validated DebriefFeatureCollection directly + feature_collection = params.feature_collection + + # Check if the collection has features + if not feature_collection.features or len(feature_collection.features) == 0: + return ShowTextCommand( + payload="No features found in the collection to toggle color", + ) + + # Get the first feature (already validated as a DebriefFeature) + first_feature = feature_collection.features[0] + + # Convert to dict for modification (use by_alias=True to get JSON keys like "marker-color") + feature_dict = first_feature.model_dump(by_alias=True) + + # Ensure the feature has properties (it should due to pydantic validation) + if "properties" not in feature_dict: + feature_dict["properties"] = {} + + # Determine the feature type and appropriate color property + data_type = feature_dict["properties"].get("dataType", "") + + # Define color property based on feature type and CSS color codes + if data_type == "reference-point": + color_property = "marker_color" + red_color = "#FF0000" + blue_color = "#0000FF" + elif data_type == "buoyfield": + color_property = "marker_color" + red_color = "#FF0000" + blue_color = "#0000FF" + elif data_type == "track": + color_property = "stroke" + red_color = "#FF0000" + blue_color = "#0000FF" + elif data_type == "zone": + color_property = "fill" + red_color = "#FF0000" + blue_color = "#0000FF" + else: + # For annotation and other types, use color property + color_property = "color" + red_color = "#FF0000" + blue_color = "#0000FF" + + # Toggle the color property + current_color = feature_dict["properties"].get(color_property, blue_color) + if current_color == red_color or current_color == "red" or current_color == "#FF0000": + feature_dict["properties"][color_property] = blue_color + else: + feature_dict["properties"][color_property] = red_color + + # Re-validate the feature directly using the appropriate feature class + # Since we know the dataType, we can validate it directly without going through the union + data_type = feature_dict["properties"].get("dataType", "") + + # Just return the feature dict directly in the UpdateFeaturesCommand + # The command will handle validation + from debrief.types.features.annotation import DebriefAnnotationFeature + from debrief.types.features.point import DebriefPointFeature + from debrief.types.features.track import DebriefTrackFeature + + # Validate using the specific feature type + if data_type == "reference-point": + validated_feature = DebriefPointFeature.model_validate(feature_dict) + elif data_type == "track": + validated_feature = DebriefTrackFeature.model_validate(feature_dict) + elif data_type == "annotation": + validated_feature = DebriefAnnotationFeature.model_validate(feature_dict) + else: + # For other types, try validating through the collection + updated_collection = DebriefFeatureCollection.model_validate( + {"type": "FeatureCollection", "features": [feature_dict]} + ) + validated_feature = updated_collection.features[0] + + # Return UpdateFeaturesCommand with the validated feature + # We need to serialize with aliases and re-parse to get proper JSON keys + feature_json = validated_feature.model_dump(by_alias=True, mode="json") + + return UpdateFeaturesCommand.model_construct( + command="updateFeatures", payload=[feature_json] + ) + + except ValidationError as e: + return ShowTextCommand( + payload=f"Input validation failed: {e.errors()[0]['msg']} at {e.errors()[0]['loc']}", + ) + except ValueError as e: + return ShowTextCommand(payload=f"Invalid feature collection data: {str(e)}") + except Exception as e: + return ShowTextCommand(payload=f"Color toggle failed: {str(e)}") diff --git a/apps/tools-mcp/src/tools_mcp/tools/feature-management/toggle_first_feature_color/samples/empty_feature_collection.json b/apps/tools-mcp/src/tools_mcp/tools/feature-management/toggle_first_feature_color/samples/empty_feature_collection.json new file mode 100644 index 00000000..66c309ea --- /dev/null +++ b/apps/tools-mcp/src/tools_mcp/tools/feature-management/toggle_first_feature_color/samples/empty_feature_collection.json @@ -0,0 +1,10 @@ +{ + "input": { + "feature_collection": { + "type": "FeatureCollection", + "features": [] + } + }, + "expectedOutput": null, + "baseline_error": "Object of type ShowTextCommand is not JSON serializable" +} \ No newline at end of file diff --git a/apps/tools-mcp/src/tools_mcp/tools/feature-management/toggle_first_feature_color/samples/multi_feature_collection.json b/apps/tools-mcp/src/tools_mcp/tools/feature-management/toggle_first_feature_color/samples/multi_feature_collection.json new file mode 100644 index 00000000..9f1fcf85 --- /dev/null +++ b/apps/tools-mcp/src/tools_mcp/tools/feature-management/toggle_first_feature_color/samples/multi_feature_collection.json @@ -0,0 +1,62 @@ +{ + "input": { + "feature_collection": { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "properties": { + "dataType": "reference-point", + "marker_color": "blue", + "name": "First Feature" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 1, + 1 + ] + } + }, + { + "type": "Feature", + "properties": { + "dataType": "reference-point", + "marker_color": "green", + "name": "Second Feature" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2, + 2 + ] + } + } + ] + } + }, + "expectedOutput": { + "command": "updateFeatures", + "payload": [ + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + 1.0, + 1.0 + ] + }, + "properties": { + "dataType": "reference-point", + "time": null, + "timeEnd": null, + "name": "First Feature", + "visible": true, + "marker_color": "#FF0000" + } + } + ] + } +} \ No newline at end of file diff --git a/apps/tools-mcp/src/tools_mcp/tools/feature-management/toggle_first_feature_color/samples/simple_feature_collection.json b/apps/tools-mcp/src/tools_mcp/tools/feature-management/toggle_first_feature_color/samples/simple_feature_collection.json new file mode 100644 index 00000000..4e31d259 --- /dev/null +++ b/apps/tools-mcp/src/tools_mcp/tools/feature-management/toggle_first_feature_color/samples/simple_feature_collection.json @@ -0,0 +1,47 @@ +{ + "input": { + "feature_collection": { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "properties": { + "dataType": "reference-point", + "marker_color": "red", + "name": "Test Point" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 0, + 0 + ] + } + } + ] + } + }, + "expectedOutput": { + "command": "updateFeatures", + "payload": [ + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + 0.0, + 0.0 + ] + }, + "properties": { + "dataType": "reference-point", + "time": null, + "timeEnd": null, + "name": "Test Point", + "visible": true, + "marker_color": "#0000FF" + } + } + ] + } +} \ No newline at end of file diff --git a/apps/tools-mcp/src/tools_mcp/tools/selection/fit_to_selection/execute.py b/apps/tools-mcp/src/tools_mcp/tools/selection/fit_to_selection/execute.py new file mode 100644 index 00000000..d1b54d66 --- /dev/null +++ b/apps/tools-mcp/src/tools_mcp/tools/selection/fit_to_selection/execute.py @@ -0,0 +1,112 @@ +"""Calculate bounds of features and set viewport to fit them.""" + +from typing import List + +# Use hierarchical imports from shared-types +from debrief.types.features import DebriefFeature +from debrief.types.states.viewport_state import ViewportState +from debrief.types.tools import DebriefCommand, SetViewportCommand, ShowTextCommand +from pydantic import BaseModel, Field + + +class FitToSelectionParameters(BaseModel): + """Parameters for fit-to-selection tool.""" + + features: List[DebriefFeature] = Field( + description="Array of Debrief features to calculate bounds for", + examples=[ + [ + { + "type": "Feature", + "id": "track-001", + "geometry": {"type": "LineString", "coordinates": [[0, 0], [1, 1]]}, + "properties": {"dataType": "track"}, + } + ] + ], + ) + + padding: float = Field( + default=0.1, + description="Additional padding around bounds as percentage (0.1 = 10%)", + examples=[0.1, 0.05, 0.2], + ) + + +def fit_to_selection(params: FitToSelectionParameters) -> DebriefCommand: + """ + Calculate bounds of features and set viewport to fit them. + + Analyzes the geometry coordinates of all features to determine the + bounding box, then sets the ViewportState to fit all features. + + Args: + params: Features to analyze and optional padding + + Returns: + SetViewportCommand to update the viewport bounds + """ + try: + if not params.features: + return ShowTextCommand(payload="No features provided to fit viewport") + + # Initialize bounds tracking + min_lng, min_lat = float("inf"), float("inf") + max_lng, max_lat = float("-inf"), float("-inf") + + def process_coordinates(coords): + """Recursively process coordinates of any geometry type.""" + nonlocal min_lng, min_lat, max_lng, max_lat + + if isinstance(coords[0], (int, float)): + # Single coordinate pair [lng, lat] + lng, lat = coords[0], coords[1] + min_lng = min(min_lng, lng) + max_lng = max(max_lng, lng) + min_lat = min(min_lat, lat) + max_lat = max(max_lat, lat) + else: + # Array of coordinates - recurse + for coord in coords: + process_coordinates(coord) + + # Process all feature geometries + for feature in params.features: + geometry = feature.geometry + if not geometry: + continue + coords = getattr(geometry, "coordinates", []) + + if coords: + process_coordinates(coords) + + # Check if we found any valid coordinates + if min_lng == float("inf"): + return ShowTextCommand(payload="No valid coordinates found in features") + + # Apply padding + lng_range = max_lng - min_lng + lat_range = max_lat - min_lat + + # Ensure minimum range for very small or point features + if lng_range < 0.001: + lng_range = 0.001 + if lat_range < 0.001: + lat_range = 0.001 + + lng_padding = lng_range * params.padding + lat_padding = lat_range * params.padding + + # Calculate bounds with padding [west, south, east, north] + bounds = [ + min_lng - lng_padding, # west + min_lat - lat_padding, # south + max_lng + lng_padding, # east + max_lat + lat_padding, # north + ] + + # Create setViewport command + return SetViewportCommand(payload=ViewportState(bounds=bounds)) + + except Exception as e: + return ShowTextCommand(payload=f"Error calculating feature bounds: {str(e)}") diff --git a/apps/tools-mcp/src/tools_mcp/tools/selection/fit_to_selection/samples/mixed_features.json b/apps/tools-mcp/src/tools_mcp/tools/selection/fit_to_selection/samples/mixed_features.json new file mode 100644 index 00000000..ac4a1449 --- /dev/null +++ b/apps/tools-mcp/src/tools_mcp/tools/selection/fit_to_selection/samples/mixed_features.json @@ -0,0 +1,81 @@ +{ + "input": { + "features": [ + { + "type": "Feature", + "id": "track-001", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + -1.0, + 50.0 + ], + [ + -0.5, + 50.5 + ], + [ + 0.0, + 51.0 + ] + ] + }, + "properties": { + "dataType": "track" + } + }, + { + "type": "Feature", + "id": "point-001", + "geometry": { + "type": "Point", + "coordinates": [ + 0.5, + 51.5 + ] + }, + "properties": { + "dataType": "reference-point" + } + }, + { + "type": "Feature", + "id": "annotation-001", + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + 1.0, + 52.0 + ], + [ + 2.0, + 52.0 + ], + [ + 2.0, + 53.0 + ], + [ + 1.0, + 53.0 + ], + [ + 1.0, + 52.0 + ] + ] + ] + }, + "properties": { + "dataType": "annotation" + } + } + ], + "padding": 0.1 + }, + "expectedOutput": null, + "baseline_error": "Object of type SetViewportCommand is not JSON serializable" +} \ No newline at end of file diff --git a/apps/tools-mcp/src/tools_mcp/tools/selection/select_all_visible/execute.py b/apps/tools-mcp/src/tools_mcp/tools/selection/select_all_visible/execute.py new file mode 100644 index 00000000..6e114588 --- /dev/null +++ b/apps/tools-mcp/src/tools_mcp/tools/selection/select_all_visible/execute.py @@ -0,0 +1,169 @@ +"""Select all features that are visible within the current viewport bounds.""" + +from typing import Any, List, Union + +# Use hierarchical imports from shared-types +from debrief.types.features import DebriefFeatureCollection +from debrief.types.states.selection_state import SelectionState +from debrief.types.states.viewport_state import ViewportState +from debrief.types.tools import DebriefCommand, SetSelectionCommand, ShowTextCommand +from pydantic import BaseModel, Field, field_validator + + +class SelectAllVisibleParameters(BaseModel): + """Parameters for select-all-visible tool.""" + + # Use DebriefFeatureCollection type for auto-injection, but accept unvalidated data + # The validator converts it to a plain dict to handle custom dataTypes + feature_collection: DebriefFeatureCollection = Field( + description="GeoJSON FeatureCollection containing features to check for visibility", + examples=[ + { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "id": "track-001", + "geometry": { + "type": "LineString", + "coordinates": [[-1.0, 52.0], [-1.1, 52.1]], + }, + "properties": {"dataType": "track", "name": "Track 1"}, + } + ], + } + ], + ) + + viewport_state: ViewportState = Field( + description="Current viewport state with bounds [west, south, east, north]", + examples=[{"bounds": [-2.0, 51.0, 0.0, 53.0]}], + ) + + @field_validator("feature_collection", mode="before") + @classmethod + def accept_unvalidated_fc(cls, v: Any) -> Any: + """Accept feature collection as-is without strict validation. + + This allows custom dataType values that aren't in the strict union. + We return the raw dict instead of a validated Pydantic model. + """ + # If it's already a dict or DebriefFeatureCollection, pass it through + if isinstance(v, (dict, DebriefFeatureCollection)): + # Convert to dict if it's a Pydantic model + if isinstance(v, DebriefFeatureCollection): + return v.model_dump() + return v + return v + + +def select_all_visible(params: SelectAllVisibleParameters) -> DebriefCommand: + """ + Select all features that are visible within the current viewport bounds. + + Analyzes the viewport bounds and feature geometries to determine which + features are contained within or overlap with the current viewport. + Returns a setSelection command with the IDs of all visible features. + + Args: + params: Parameters containing feature_collection and viewport_state + + Returns: + SetSelectionCommand to update the selection with visible feature IDs + """ + try: + # Check if we have viewport bounds (Pydantic model access) + if not params.viewport_state or not params.viewport_state.bounds: + return ShowTextCommand( + payload="No viewport bounds available", + ) + + # feature_collection is a dict (from validator), access as dict + fc_dict = ( + params.feature_collection + if isinstance(params.feature_collection, dict) + else params.feature_collection.model_dump() + ) + + # Check if we have features (Dict access) + if not fc_dict or not fc_dict.get("features"): + return ShowTextCommand(payload="No features available") + + # Extract viewport bounds [west, south, east, north] (Pydantic model access) + viewport_bounds = params.viewport_state.bounds + west, south, east, north = viewport_bounds + + # Find features that are visible (intersect with viewport) + visible_feature_ids: List[Union[str, int]] = [] + + def bounds_intersect( + feature_west: float, feature_south: float, feature_east: float, feature_north: float + ) -> bool: + """Check if feature bounds intersect with viewport bounds.""" + return not ( + feature_east < west + or feature_west > east + or feature_north < south + or feature_south > north + ) + + def get_feature_bounds(coordinates): + """Calculate bounds for any geometry type.""" + min_lng, min_lat = float("inf"), float("inf") + max_lng, max_lat = float("-inf"), float("-inf") + + def process_coords(coords): + nonlocal min_lng, min_lat, max_lng, max_lat + if isinstance(coords[0], (int, float)): + # Single coordinate pair [lng, lat] + lng, lat = coords[0], coords[1] + min_lng = min(min_lng, lng) + max_lng = max(max_lng, lng) + min_lat = min(min_lat, lat) + max_lat = max(max_lat, lat) + else: + # Array of coordinates - recurse + for coord in coords: + process_coords(coord) + + process_coords(coordinates) + return min_lng, min_lat, max_lng, max_lat + + # Check each feature for visibility (dict access) + for feature in fc_dict["features"]: + geometry = feature.get("geometry") + if not geometry or "coordinates" not in geometry: + continue + + coordinates = geometry["coordinates"] + if not coordinates: + continue + + try: + # Calculate feature bounds + feature_west, feature_south, feature_east, feature_north = get_feature_bounds( + coordinates + ) + + # Check if feature bounds intersect with viewport bounds + if bounds_intersect(feature_west, feature_south, feature_east, feature_north): + # Feature is visible - add its ID + feature_id = ( + feature.get("id") + if feature.get("id") is not None + else f"feature_{len(visible_feature_ids)}" + ) + visible_feature_ids.append(feature_id) + + except Exception: + # Skip features with invalid geometry + continue + + # Create selection state with visible feature IDs + selection_state = SelectionState(selectedIds=visible_feature_ids) + + # Return setSelection command + return SetSelectionCommand(payload=selection_state) + + except Exception as e: + return ShowTextCommand(payload=f"Error selecting visible features: {str(e)}") diff --git a/apps/tools-mcp/src/tools_mcp/tools/selection/select_all_visible/samples/mixed_tracks_and_points.json b/apps/tools-mcp/src/tools_mcp/tools/selection/select_all_visible/samples/mixed_tracks_and_points.json new file mode 100644 index 00000000..991547b9 --- /dev/null +++ b/apps/tools-mcp/src/tools_mcp/tools/selection/select_all_visible/samples/mixed_tracks_and_points.json @@ -0,0 +1,95 @@ +{ + "input": { + "feature_collection": { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "id": "track-001", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + -1.0, + 52.0 + ], + [ + -1.1, + 52.1 + ], + [ + -1.2, + 52.2 + ] + ] + }, + "properties": { + "dataType": "track", + "name": "Track Within Viewport" + } + }, + { + "type": "Feature", + "id": "track-002", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 5.0, + 45.0 + ], + [ + 6.0, + 46.0 + ] + ] + }, + "properties": { + "dataType": "track", + "name": "Track Outside Viewport" + } + }, + { + "type": "Feature", + "id": "point-001", + "geometry": { + "type": "Point", + "coordinates": [ + -0.5, + 52.5 + ] + }, + "properties": { + "dataType": "reference-point", + "name": "Point Within Viewport" + } + }, + { + "type": "Feature", + "id": "point-002", + "geometry": { + "type": "Point", + "coordinates": [ + 10.0, + 60.0 + ] + }, + "properties": { + "dataType": "reference-point", + "name": "Point Outside Viewport" + } + } + ] + }, + "viewport_state": { + "bounds": [ + -2.0, + 51.0, + 0.0, + 53.0 + ] + } + }, + "expectedOutput": null, + "baseline_error": "Object of type SetSelectionCommand is not JSON serializable" +} \ No newline at end of file diff --git a/apps/tools-mcp/src/tools_mcp/tools/selection/select_all_visible/samples/polygon_zone_with_point.json b/apps/tools-mcp/src/tools_mcp/tools/selection/select_all_visible/samples/polygon_zone_with_point.json new file mode 100644 index 00000000..8cecf075 --- /dev/null +++ b/apps/tools-mcp/src/tools_mcp/tools/selection/select_all_visible/samples/polygon_zone_with_point.json @@ -0,0 +1,69 @@ +{ + "input": { + "feature_collection": { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "id": "zone-001", + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + -1.5, + 51.5 + ], + [ + -0.5, + 51.5 + ], + [ + -0.5, + 52.5 + ], + [ + -1.5, + 52.5 + ], + [ + -1.5, + 51.5 + ] + ] + ] + }, + "properties": { + "dataType": "annotation", + "name": "Zone Partially In Viewport" + } + }, + { + "type": "Feature", + "id": "annotation-001", + "geometry": { + "type": "Point", + "coordinates": [ + -1.0, + 52.0 + ] + }, + "properties": { + "dataType": "reference-point", + "name": "Point At Center" + } + } + ] + }, + "viewport_state": { + "bounds": [ + -1.5, + 51.8, + -0.5, + 52.8 + ] + } + }, + "expectedOutput": null, + "baseline_error": "Object of type SetSelectionCommand is not JSON serializable" +} \ No newline at end of file diff --git a/apps/tools-mcp/src/tools_mcp/tools/selection/select_feature_start_time/execute.py b/apps/tools-mcp/src/tools_mcp/tools/selection/select_feature_start_time/execute.py new file mode 100644 index 00000000..834157eb --- /dev/null +++ b/apps/tools-mcp/src/tools_mcp/tools/selection/select_feature_start_time/execute.py @@ -0,0 +1,101 @@ +"""Set current time to the earliest timestamp of selected features.""" + +from datetime import datetime, timezone +from typing import List + +# Use hierarchical imports from shared-types +from debrief.types.features import DebriefFeature +from debrief.types.states import TimeState +from debrief.types.tools import DebriefCommand, SetTimeStateCommand, ShowTextCommand +from pydantic import BaseModel, Field + + +class SelectFeatureStartTimeParameters(BaseModel): + """Parameters for select_feature_start_time tool.""" + + features: List[DebriefFeature] = Field( + description="Array of Debrief features to analyze for timestamps", + examples=[ + [ + { + "type": "Feature", + "id": "track-001", + "geometry": {"type": "LineString", "coordinates": [[0, 0], [1, 1]]}, + "properties": { + "dataType": "track", + "timestamps": ["2024-01-01T10:00:00Z", "2024-01-01T11:00:00Z"], + }, + } + ] + ], + ) + + current_time_state: TimeState = Field( + description="Current time state to update", + examples=[ + { + "current": "2024-01-01T12:00:00Z", + "start": "2024-01-01T00:00:00Z", + "end": "2024-01-01T23:59:59Z", + } + ], + ) + + +def select_feature_start_time(params: SelectFeatureStartTimeParameters) -> DebriefCommand: + """ + Set current time state to the earliest timestamp of selected features. + + Analyzes the timestamps property of features and sets TimeState.current + to the earliest timestamp found across all features. + + Args: + params: Features to analyze and current TimeState + + Returns: + SetTimeCommand to update the time state + """ + try: + earliest_timestamp = None + + for feature in params.features: + # Get timestamps from feature properties + properties = feature.properties + if not properties: + continue + timestamps = getattr(properties, "timestamps", None) + + if not timestamps: + continue + + # Parse timestamps and find the earliest + for timestamp_value in timestamps: + timestamp = None + + if isinstance(timestamp_value, datetime): + timestamp = timestamp_value + elif isinstance(timestamp_value, str): + try: + timestamp = datetime.fromisoformat(timestamp_value.replace("Z", "+00:00")) + except ValueError: + continue + else: + continue + + if timestamp.tzinfo is None: + timestamp = timestamp.replace(tzinfo=timezone.utc) + + if earliest_timestamp is None or timestamp < earliest_timestamp: + earliest_timestamp = timestamp + + if earliest_timestamp is None: + return ShowTextCommand(payload="No valid timestamps found in features") + + updated_time_state = params.current_time_state.model_copy( + update={"current": earliest_timestamp} + ) + + return SetTimeStateCommand(payload=updated_time_state) + + except Exception as e: + return ShowTextCommand(payload=f"Error finding earliest timestamp: {str(e)}") diff --git a/apps/tools-mcp/src/tools_mcp/tools/selection/select_feature_start_time/samples/sample_tracks.json b/apps/tools-mcp/src/tools_mcp/tools/selection/select_feature_start_time/samples/sample_tracks.json new file mode 100644 index 00000000..daa58a44 --- /dev/null +++ b/apps/tools-mcp/src/tools_mcp/tools/selection/select_feature_start_time/samples/sample_tracks.json @@ -0,0 +1,71 @@ +{ + "input": { + "features": [ + { + "type": "Feature", + "id": "track-001", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 0, + 0 + ], + [ + 0.1, + 0.1 + ], + [ + 0.2, + 0.2 + ] + ] + }, + "properties": { + "dataType": "track", + "timestamps": [ + "2024-01-01T10:30:00Z", + "2024-01-01T10:45:00Z", + "2024-01-01T11:00:00Z" + ] + } + }, + { + "type": "Feature", + "id": "track-002", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 1, + 1 + ], + [ + 1.1, + 1.1 + ], + [ + 1.2, + 1.2 + ] + ] + }, + "properties": { + "dataType": "track", + "timestamps": [ + "2024-01-01T09:15:00Z", + "2024-01-01T09:30:00Z", + "2024-01-01T09:45:00Z" + ] + } + } + ], + "current_time_state": { + "current": "2025-09-03T12:00:00Z", + "start": "2025-09-03T08:00:00Z", + "end": "2025-09-03T14:00:00Z" + } + }, + "expectedOutput": null, + "baseline_error": "Object of type SetTimeStateCommand is not JSON serializable" +} \ No newline at end of file diff --git a/apps/tools-mcp/src/tools_mcp/tools/text/word_count/execute.py b/apps/tools-mcp/src/tools_mcp/tools/text/word_count/execute.py new file mode 100644 index 00000000..1159ccb2 --- /dev/null +++ b/apps/tools-mcp/src/tools_mcp/tools/text/word_count/execute.py @@ -0,0 +1,54 @@ +"""Word counting tool for text analysis.""" + +from debrief.types.tools import DebriefCommand, ShowTextCommand +from pydantic import BaseModel, Field + + +class WordCountParameters(BaseModel): + """Parameters for the word_count tool.""" + + text: str = Field( + description="The input text block to count words from", + min_length=0, + examples=[ + "Hello world", + "This is a longer text with multiple words to count", + "", + "Single", + ], + ) + + +def word_count(params: WordCountParameters) -> DebriefCommand: + """ + Count the number of words in a given block of text. + + This function splits the input text by whitespace and returns the count + of resulting words as a ToolVault command object. Empty strings and strings + containing only whitespace will return 0. + + Args: + params: WordCountParameters object with text field + + Returns: + DebriefCommand: ToolVault command object with word count result + + Examples: + >>> params = WordCountParameters(text="Hello world") + >>> result = word_count(params) + >>> result["command"] + 'showText' + >>> result["payload"] + 'Word count: 2' + """ + # Extract text from Pydantic parameters + text = params.text + + # Count words + if not text or not text.strip(): + count = 0 + else: + count = len(text.strip().split()) + + # Return ToolVault command object + return ShowTextCommand(payload=f"Word count: {count}") diff --git a/apps/tools-mcp/src/tools_mcp/tools/text/word_count/samples/empty_text.json b/apps/tools-mcp/src/tools_mcp/tools/text/word_count/samples/empty_text.json new file mode 100644 index 00000000..6307aeb6 --- /dev/null +++ b/apps/tools-mcp/src/tools_mcp/tools/text/word_count/samples/empty_text.json @@ -0,0 +1,7 @@ +{ + "input": { + "text": "" + }, + "expectedOutput": null, + "baseline_error": "Object of type ShowTextCommand is not JSON serializable" +} \ No newline at end of file diff --git a/apps/tools-mcp/src/tools_mcp/tools/text/word_count/samples/paragraph_text.json b/apps/tools-mcp/src/tools_mcp/tools/text/word_count/samples/paragraph_text.json new file mode 100644 index 00000000..cccae8e0 --- /dev/null +++ b/apps/tools-mcp/src/tools_mcp/tools/text/word_count/samples/paragraph_text.json @@ -0,0 +1,7 @@ +{ + "input": { + "text": "The quick brown fox jumps over the lazy dog. This sentence contains every letter of the alphabet and is commonly used for typing practice." + }, + "expectedOutput": null, + "baseline_error": "Object of type ShowTextCommand is not JSON serializable" +} \ No newline at end of file diff --git a/apps/tools-mcp/src/tools_mcp/tools/text/word_count/samples/simple_text.json b/apps/tools-mcp/src/tools_mcp/tools/text/word_count/samples/simple_text.json new file mode 100644 index 00000000..be293d62 --- /dev/null +++ b/apps/tools-mcp/src/tools_mcp/tools/text/word_count/samples/simple_text.json @@ -0,0 +1,7 @@ +{ + "input": { + "text": "Hello world" + }, + "expectedOutput": null, + "baseline_error": "Object of type ShowTextCommand is not JSON serializable" +} \ No newline at end of file diff --git a/apps/tools-mcp/src/tools_mcp/tools/track-analysis/track_speed_filter/execute.py b/apps/tools-mcp/src/tools_mcp/tools/track-analysis/track_speed_filter/execute.py new file mode 100644 index 00000000..a2810ddc --- /dev/null +++ b/apps/tools-mcp/src/tools_mcp/tools/track-analysis/track_speed_filter/execute.py @@ -0,0 +1,181 @@ +"""Track speed filtering tool for maritime analysis.""" + +import math + +from debrief.types.features import DebriefTrackFeature +from debrief.types.tools import DebriefCommand, ShowDataCommand, ShowTextCommand +from pydantic import BaseModel, Field, ValidationError + + +class TrackSpeedFilterParameters(BaseModel): + """Parameters for the track_speed_filter tool.""" + + track_feature: DebriefTrackFeature = Field( + description="A GeoJSON track feature conforming to DebriefTrackFeature schema with LineString or MultiLineString geometry", + examples=[ + { + "type": "Feature", + "id": "track_001", + "geometry": { + "type": "LineString", + "coordinates": [[0, 0], [0.01, 0.01], [0.02, 0.02]], + }, + "properties": { + "dataType": "track", + "timestamps": [ + "2023-01-01T10:00:00Z", + "2023-01-01T10:01:00Z", + "2023-01-01T10:02:00Z", + ], + "name": "Sample Track", + "description": "Test track for speed analysis", + }, + } + ], + ) + + min_speed: float = Field( + default=10.0, + description="Minimum speed threshold in knots", + ge=0.0, + examples=[5.0, 10.0, 15.0, 20.0], + ) + + +def track_speed_filter(params: TrackSpeedFilterParameters) -> DebriefCommand: + """ + Find timestamps where track speed equals or exceeds a minimum threshold. + + This function analyzes a Debrief track feature and calculates speeds between + consecutive coordinate points, returning timestamps where the calculated speed + meets or exceeds the specified minimum threshold. Speed is calculated using + the haversine formula for geographic coordinates and assumes timestamps are + evenly spaced for time calculations. + + Args: + params: TrackSpeedFilterParameters containing track_feature and min_speed + + Returns: + Dict[str, Any]: ToolVault command object containing filtered timestamps + or appropriate message if no data available + + Examples: + >>> from pydantic import ValidationError + >>> params = TrackSpeedFilterParameters( + ... track_feature={ + ... "type": "Feature", + ... "id": "test", + ... "geometry": { + ... "type": "LineString", + ... "coordinates": [[0, 0], [0.01, 0]] + ... ), + ... "properties": { + ... "dataType": "track", + ... "timestamps": ["2023-01-01T10:00:00Z", "2023-01-01T10:01:00Z"] + ... ) + ... }, + ... min_speed=10.0 + ... ) + >>> result = track_speed_filter(params) + >>> result["command"] + 'showText' + """ + try: + # Extract validated parameters from Pydantic model + track_feature = params.track_feature # Direct access to validated DebriefTrackFeature + min_speed = params.min_speed + + geometry = track_feature.geometry + properties = track_feature.properties + + # Extract coordinates based on geometry type + # geojson-pydantic provides coordinates as lists directly + coordinates = [] + if geometry.type == "LineString": + # LineString: coordinates is List[List[float]] + coordinates = geometry.coordinates + elif geometry.type == "MultiLineString": + # MultiLineString: coordinates is List[List[List[float]]] + for line in geometry.coordinates: + coordinates.extend(line) + + if len(coordinates) < 2: + return ShowTextCommand( + payload="Track must have at least 2 coordinate points to calculate speed", + ) + + timestamps = properties.timestamps + if timestamps is None: + return ShowTextCommand( + payload="Track feature must have timestamps to calculate speed", + ) + + if len(timestamps) != len(coordinates): + return ShowTextCommand( + payload=f"Timestamp count ({len(timestamps)}) must match coordinate count ({len(coordinates)})", + ) + + # Calculate speeds and find timestamps exceeding threshold + high_speed_times = [] + + for i in range(len(coordinates) - 1): + # Get consecutive coordinate pairs + coord1 = coordinates[i] + coord2 = coordinates[i + 1] + + if len(coord1) < 2 or len(coord2) < 2: + continue + + # Calculate distance using haversine formula + # coordinates is now List[List[float]], so coord1 and coord2 are List[float] + lat1, lon1 = math.radians(coord1[1]), math.radians(coord1[0]) + lat2, lon2 = math.radians(coord2[1]), math.radians(coord2[0]) + + dlat = lat2 - lat1 + dlon = lon2 - lon1 + + a = math.sin(dlat / 2) ** 2 + math.cos(lat1) * math.cos(lat2) * math.sin(dlon / 2) ** 2 + c = 2 * math.asin(math.sqrt(a)) + + # Distance in nautical miles (Earth's radius ≈ 3440.065 nautical miles) + distance_nm = 3440.065 * c + + # Assume 1 minute between timestamps for speed calculation + # This is a simplification - in reality you'd parse the timestamp difference + time_hours = 1.0 / 60.0 # 1 minute in hours + + speed_knots = distance_nm / time_hours + + # If speed meets threshold, add the timestamp for this segment + if speed_knots >= min_speed: + # Convert datetime to ISO string for display + timestamp_str = ( + timestamps[i + 1].isoformat() + if hasattr(timestamps[i + 1], "isoformat") + else str(timestamps[i + 1]) + ) + high_speed_times.append(timestamp_str) + + if not high_speed_times: + return ShowTextCommand( + payload=f"No timestamps found where speed >= {min_speed} knots", + ) + + # Return structured data for better visualization + return ShowDataCommand( + payload={ + "title": f"Track Speed Filter Results (>= {min_speed} knots)", + "count": len(high_speed_times), + "min_speed_threshold": min_speed, + "timestamps": high_speed_times, + }, + ) + + except ValidationError as e: + return ShowTextCommand( + payload=f"Input validation failed: {e.errors()[0]['msg']} at {e.errors()[0]['loc']}", + ) + except ValueError as e: + return ShowTextCommand(payload=f"Invalid track data: {str(e)}") + except Exception as e: + return ShowTextCommand(payload=f"Speed calculation failed: {str(e)}") diff --git a/apps/tools-mcp/src/tools_mcp/tools/track-analysis/track_speed_filter/samples/sample_track_high_speed.json b/apps/tools-mcp/src/tools_mcp/tools/track-analysis/track_speed_filter/samples/sample_track_high_speed.json new file mode 100644 index 00000000..1087dbd9 --- /dev/null +++ b/apps/tools-mcp/src/tools_mcp/tools/track-analysis/track_speed_filter/samples/sample_track_high_speed.json @@ -0,0 +1,43 @@ +{ + "input": { + "track_feature": { + "type": "Feature", + "id": "sample_track_002", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 0.0, + 51.0 + ], + [ + 0.1, + 51.0 + ], + [ + 0.2, + 51.0 + ], + [ + 0.3, + 51.0 + ] + ] + }, + "properties": { + "dataType": "track", + "name": "Sample High Speed Track", + "description": "A track with higher speeds for testing threshold filtering", + "timestamps": [ + "2023-01-01T12:00:00Z", + "2023-01-01T12:01:00Z", + "2023-01-01T12:02:00Z", + "2023-01-01T12:03:00Z" + ] + } + }, + "min_speed": 10.0 + }, + "expectedOutput": null, + "baseline_error": "Object of type ShowDataCommand is not JSON serializable" +} \ No newline at end of file diff --git a/apps/tools-mcp/src/tools_mcp/tools/track-analysis/track_speed_filter/samples/sample_track_low_speed.json b/apps/tools-mcp/src/tools_mcp/tools/track-analysis/track_speed_filter/samples/sample_track_low_speed.json new file mode 100644 index 00000000..d7710b8d --- /dev/null +++ b/apps/tools-mcp/src/tools_mcp/tools/track-analysis/track_speed_filter/samples/sample_track_low_speed.json @@ -0,0 +1,43 @@ +{ + "input": { + "track_feature": { + "type": "Feature", + "id": "sample_track_001", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + -0.1, + 51.5 + ], + [ + -0.099, + 51.501 + ], + [ + -0.098, + 51.502 + ], + [ + -0.097, + 51.503 + ] + ] + }, + "properties": { + "dataType": "track", + "name": "Sample Low Speed Track", + "description": "A track with relatively low speeds for testing", + "timestamps": [ + "2023-01-01T10:00:00Z", + "2023-01-01T10:01:00Z", + "2023-01-01T10:02:00Z", + "2023-01-01T10:03:00Z" + ] + } + }, + "min_speed": 5.0 + }, + "expectedOutput": null, + "baseline_error": "Object of type ShowTextCommand is not JSON serializable" +} \ No newline at end of file diff --git a/apps/tools-mcp/src/tools_mcp/tools/track-analysis/track_speed_filter_fast/execute.py b/apps/tools-mcp/src/tools_mcp/tools/track-analysis/track_speed_filter_fast/execute.py new file mode 100644 index 00000000..9b6f6391 --- /dev/null +++ b/apps/tools-mcp/src/tools_mcp/tools/track-analysis/track_speed_filter_fast/execute.py @@ -0,0 +1,207 @@ +"""Fast track speed filtering tool using pre-calculated speeds.""" + +from typing import List + +from debrief.types.features import DebriefTrackFeature +from debrief.types.tools import DebriefCommand, ShowDataCommand, ShowTextCommand +from pydantic import BaseModel, Field, ValidationError, model_validator + + +class TrackFeatureWithSpeeds(BaseModel): + """A Track feature that extends the base Track with REQUIRED speeds array.""" + + # Use the shared-types DebriefTrackFeature directly + track_feature: DebriefTrackFeature = Field( + description="Track feature data that will be validated as DebriefTrackFeature + speeds constraint" + ) + + @model_validator(mode="after") + def validate_track_with_speeds(self): + """Validate that track feature has required speeds array.""" + track_feature = self.track_feature + + # Additional constraint: require speeds array + properties = track_feature.properties + speeds = getattr(properties, "speeds", None) if hasattr(properties, "speeds") else None + + if speeds is None: + raise ValueError( + "Track feature must have a 'speeds' array in properties for fast filtering" + ) + + if not isinstance(speeds, list): + raise ValueError("speeds property must be a list") + + if not speeds: # Empty list + raise ValueError("speeds array cannot be empty") + + if not all(isinstance(s, (int, float)) and s >= 0 for s in speeds): + raise ValueError("All speeds must be non-negative numbers") + + # Validate array length alignment + geometry = track_feature.geometry + geom_type = geometry.type + + if geom_type == "LineString": + coord_count = len(geometry.coordinates) + elif geom_type == "MultiLineString": + coord_count = sum(len(line) for line in geometry.coordinates) + else: + raise ValueError(f"Unsupported geometry type: {geom_type}") + + timestamps = properties.timestamps + + if len(speeds) != coord_count: + raise ValueError( + f"speeds array length ({len(speeds)}) must match coordinate count ({coord_count})" + ) + + if timestamps and len(timestamps) != coord_count: + raise ValueError( + f"timestamps array length ({len(timestamps)}) must match coordinate count ({coord_count})" + ) + + return self + + @property + def base_track(self) -> DebriefTrackFeature: + """Get the validated shared-types DebriefTrackFeature.""" + return self.track_feature + + @property + def speeds(self) -> List[float]: + """Get the speeds array.""" + return getattr(self.track_feature.properties, "speeds", []) + + @property + def timestamps(self) -> List[str]: + """Get the timestamps as strings.""" + # Convert datetime objects to strings if needed + timestamps = self.track_feature.properties.timestamps + if timestamps and hasattr(timestamps[0], "isoformat"): + return [ts.isoformat() for ts in timestamps] + return [str(ts) for ts in timestamps] if timestamps else [] + + +class TrackSpeedFilterFastParameters(BaseModel): + """Parameters for the track_speed_filter_fast tool.""" + + track_feature: DebriefTrackFeature = Field( + description="A Track feature conforming to shared-types Track schema, with additional required 'speeds' array in properties", + examples=[ + { + "type": "Feature", + "id": "track_fast_001", + "geometry": { + "type": "LineString", + "coordinates": [[0, 0], [0.01, 0.01], [0.02, 0.02]], + }, + "properties": { + "dataType": "track", + "timestamps": [ + "2023-01-01T10:00:00Z", + "2023-01-01T10:01:00Z", + "2023-01-01T10:02:00Z", + ], + "speeds": [15.2, 18.7, 12.3], + "name": "High Speed Track", + "description": "Track with pre-calculated speeds", + }, + } + ], + ) + + @model_validator(mode="after") + def validate_track_feature_with_speeds(self): + """Create and validate the constrained TrackFeatureWithSpeeds.""" + # Use our constrained wrapper for validation + constrained_track = TrackFeatureWithSpeeds(track_feature=self.track_feature) + self._constrained_track = constrained_track + return self + + min_speed: float = Field( + default=10.0, + description="Minimum speed threshold in knots", + ge=0.0, + examples=[5.0, 10.0, 15.0, 20.0], + ) + + +def track_speed_filter_fast(params: TrackSpeedFilterFastParameters) -> DebriefCommand: + """ + Filter track timestamps using pre-calculated speeds array for fast processing. + + This function analyzes a Track feature that already contains pre-calculated speeds + and returns timestamps where the speed meets or exceeds the specified minimum + threshold. This is much faster than the standard track_speed_filter as it + bypasses the haversine distance calculations. + + Args: + params: TrackSpeedFilterFastParameters containing track_feature with speeds and min_speed + + Returns: + Dict[str, Any]: ToolVault command object containing filtered timestamps + or appropriate message + + Examples: + >>> from pydantic import ValidationError + >>> params = TrackSpeedFilterFastParameters( + ... track_feature={ + ... "type": "Feature", + ... "id": "test", + ... "geometry": { + ... "type": "LineString", + ... "coordinates": [[0, 0], [0.01, 0.01]] + ... }, + ... "properties": { + ... "dataType": "track", + ... "timestamps": ["2023-01-01T10:00:00Z", "2023-01-01T10:01:00Z"], + ... "speeds": [15.0, 8.0] + ... ) + ... }, + ... min_speed=10.0 + ... ) + >>> result = track_speed_filter_fast(params) + >>> result["command"] + 'showText' + """ + try: + # Access the constrained track wrapper created during validation + constrained_track = params._constrained_track + min_speed = params.min_speed + + # Get speeds and timestamps from the wrapper's convenience properties + speeds = constrained_track.speeds # List[float] - guaranteed to exist and be valid + timestamps = constrained_track.timestamps # List[str] - guaranteed to match speeds length + + # Find timestamps where speed meets or exceeds threshold + high_speed_times = [] + + for i, speed in enumerate(speeds): + if speed >= min_speed: + high_speed_times.append(timestamps[i]) + + if not high_speed_times: + return ShowTextCommand( + payload=f"No timestamps found where speed >= {min_speed} knots", + ) + + # Return structured data for better visualization + return ShowDataCommand( + payload={ + "title": f"Fast Track Speed Filter Results (>= {min_speed} knots)", + "count": len(high_speed_times), + "min_speed_threshold": min_speed, + "timestamps": high_speed_times, + "method": "pre-calculated speeds", + }, + ) + + except ValidationError as e: + return ShowTextCommand( + payload=f"Input validation failed: {e.errors()[0]['msg']} at {e.errors()[0]['loc']}", + ) + except ValueError as e: + return ShowTextCommand(payload=f"Invalid track data: {str(e)}") + except Exception as e: + return ShowTextCommand(payload=f"Speed filtering failed: {str(e)}") diff --git a/apps/tools-mcp/src/tools_mcp/tools/track-analysis/track_speed_filter_fast/samples/track_with_speeds_high.json b/apps/tools-mcp/src/tools_mcp/tools/track-analysis/track_speed_filter_fast/samples/track_with_speeds_high.json new file mode 100644 index 00000000..6ac53e14 --- /dev/null +++ b/apps/tools-mcp/src/tools_mcp/tools/track-analysis/track_speed_filter_fast/samples/track_with_speeds_high.json @@ -0,0 +1,49 @@ +{ + "input": { + "track_feature": { + "type": "Feature", + "id": "fast_track_001", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 0.0, + 51.0 + ], + [ + 0.1, + 51.0 + ], + [ + 0.2, + 51.0 + ], + [ + 0.3, + 51.0 + ] + ] + }, + "properties": { + "dataType": "track", + "name": "High Speed Track with Pre-calculated Speeds", + "description": "A track with pre-calculated speeds for fast filtering", + "timestamps": [ + "2023-01-01T14:00:00Z", + "2023-01-01T14:01:00Z", + "2023-01-01T14:02:00Z", + "2023-01-01T14:03:00Z" + ], + "speeds": [ + 25.5, + 18.2, + 12.8, + 22.1 + ] + } + }, + "min_speed": 15.0 + }, + "expectedOutput": null, + "baseline_error": "Object of type ShowDataCommand is not JSON serializable" +} \ No newline at end of file diff --git a/apps/tools-mcp/src/tools_mcp/tools/track-analysis/track_speed_filter_fast/samples/track_with_speeds_low.json b/apps/tools-mcp/src/tools_mcp/tools/track-analysis/track_speed_filter_fast/samples/track_with_speeds_low.json new file mode 100644 index 00000000..11f6c27d --- /dev/null +++ b/apps/tools-mcp/src/tools_mcp/tools/track-analysis/track_speed_filter_fast/samples/track_with_speeds_low.json @@ -0,0 +1,43 @@ +{ + "input": { + "track_feature": { + "type": "Feature", + "id": "fast_track_002", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + -1.0, + 50.5 + ], + [ + -0.9, + 50.5 + ], + [ + -0.8, + 50.5 + ] + ] + }, + "properties": { + "dataType": "track", + "name": "Low Speed Track", + "description": "A track with mostly low speeds for testing threshold filtering", + "timestamps": [ + "2023-01-01T16:00:00Z", + "2023-01-01T16:01:00Z", + "2023-01-01T16:02:00Z" + ], + "speeds": [ + 3.2, + 7.8, + 5.1 + ] + } + }, + "min_speed": 10.0 + }, + "expectedOutput": null, + "baseline_error": "Object of type ShowTextCommand is not JSON serializable" +} \ No newline at end of file diff --git a/apps/tools-mcp/src/tools_mcp/tools/viewport/viewport_grid_generator/execute.py b/apps/tools-mcp/src/tools_mcp/tools/viewport/viewport_grid_generator/execute.py new file mode 100644 index 00000000..19c2fb79 --- /dev/null +++ b/apps/tools-mcp/src/tools_mcp/tools/viewport/viewport_grid_generator/execute.py @@ -0,0 +1,134 @@ +"""Viewport grid generator tool for maritime analysis.""" + +from debrief.types.states.viewport_state import ViewportState +from debrief.types.tools import AddFeaturesCommand, DebriefCommand, ShowTextCommand +from pydantic import BaseModel, Field, ValidationError + + +class ViewportGridGeneratorParameters(BaseModel): + """Parameters for the viewport_grid_generator tool.""" + + viewport_state: ViewportState = Field( + description="Viewport state containing bounds as [west, south, east, north] in decimal degrees", + examples=[ + {"bounds": [-1.0, -1.0, 1.0, 1.0]}, + {"bounds": [-122.5, 37.7, -122.3, 37.9]}, + {"bounds": [0.0, 50.0, 2.0, 52.0]}, + ], + ) + + lat_interval: float = Field( + description="Latitude interval between grid points in decimal degrees", + gt=0.0, + examples=[0.1, 0.5, 1.0], + ) + + lon_interval: float = Field( + description="Longitude interval between grid points in decimal degrees", + gt=0.0, + examples=[0.1, 0.5, 1.0], + ) + + +def viewport_grid_generator(params: ViewportGridGeneratorParameters) -> DebriefCommand: + """ + Generate a grid of points within a viewport area at specified intervals. + + This function creates a MultiPoint GeoJSON feature containing a regular grid + of points within the specified viewport bounds. The grid spacing is determined + by the latitude and longitude interval parameters. Points are generated at + regular intervals starting from the southwest corner of the viewport. + + Args: + params: ViewportGridGeneratorParameters containing viewport_state, lat_interval, and lon_interval + + Returns: + Dict[str, Any]: ToolVault command object to add the generated MultiPoint + feature to the current feature collection + + Examples: + >>> from pydantic import ValidationError + >>> params = ViewportGridGeneratorParameters( + ... viewport_state={"bounds": [-1.0, -1.0, 1.0, 1.0]}, + ... lat_interval=0.5, + ... lon_interval=0.5 + ... ) + >>> result = viewport_grid_generator(params) + >>> result["command"] + 'addFeatures' + >>> len(result["payload"]) + 1 + >>> result["payload"][0]["geometry"]["type"] + 'MultiPoint' + """ + try: + # Extract validated parameters from the ViewportState + viewport_bounds = params.viewport_state.bounds + lat_interval = params.lat_interval + lon_interval = params.lon_interval + + west, south, east, north = viewport_bounds + + # Validate bounds relationship (ViewportState should already validate this but let's be explicit) + if west >= east: + return ShowTextCommand( + payload=f"Invalid viewport bounds: west ({west}) must be less than east ({east})", + ) + + if south >= north: + return ShowTextCommand( + payload=f"Invalid viewport bounds: south ({south}) must be less than north ({north})", + ) + + # Check for reasonable intervals to prevent excessive point generation + max_points = 10000 # Reasonable limit to prevent browser overload + estimated_lat_points = int((north - south) / lat_interval) + 1 + estimated_lon_points = int((east - west) / lon_interval) + 1 + estimated_total_points = estimated_lat_points * estimated_lon_points + + if estimated_total_points > max_points: + return ShowTextCommand( + payload=f"Grid would generate {estimated_total_points} points (max: {max_points}). " + f"Please increase intervals or reduce viewport size.", + ) + + # Generate grid points + grid_points = [] + current_lat = south + + while current_lat <= north: + current_lon = west + while current_lon <= east: + grid_points.append([current_lon, current_lat]) + current_lon += lon_interval + current_lat += lat_interval + + if not grid_points: + return ShowTextCommand( + payload="No grid points generated. Check interval values and viewport bounds.", + ) + + # Create MultiPoint feature + multipoint_feature = { + "type": "Feature", + "id": "generated_grid", + "geometry": {"type": "MultiPoint", "coordinates": grid_points}, + "properties": { + "dataType": "annotation", + "annotationType": "boundary", + "name": "Generated Grid", + "description": f"Grid with {len(grid_points)} points at {lat_interval}° lat × {lon_interval}° lon intervals", + "color": "#0066CC", + }, + } + + return AddFeaturesCommand(payload=[multipoint_feature]) + + except ValidationError as e: + return ShowTextCommand( + payload=f"Input validation failed: {e.errors()[0]['msg']} at {e.errors()[0]['loc']}", + ) + except ValueError as e: + return ShowTextCommand(payload=f"Invalid viewport data: {str(e)}") + except Exception as e: + return ShowTextCommand(payload=f"Grid generation failed: {str(e)}") diff --git a/apps/tools-mcp/src/tools_mcp/tools/viewport/viewport_grid_generator/samples/london_area_grid.json b/apps/tools-mcp/src/tools_mcp/tools/viewport/viewport_grid_generator/samples/london_area_grid.json new file mode 100644 index 00000000..86fdf32e --- /dev/null +++ b/apps/tools-mcp/src/tools_mcp/tools/viewport/viewport_grid_generator/samples/london_area_grid.json @@ -0,0 +1,16 @@ +{ + "input": { + "viewport_state": { + "bounds": [ + -0.2, + 51.4, + 0.1, + 51.6 + ] + }, + "lat_interval": 0.05, + "lon_interval": 0.05 + }, + "expectedOutput": null, + "baseline_error": "Object of type ShowTextCommand is not JSON serializable" +} \ No newline at end of file diff --git a/apps/tools-mcp/src/tools_mcp/tools/viewport/viewport_grid_generator/samples/small_grid.json b/apps/tools-mcp/src/tools_mcp/tools/viewport/viewport_grid_generator/samples/small_grid.json new file mode 100644 index 00000000..f29db67d --- /dev/null +++ b/apps/tools-mcp/src/tools_mcp/tools/viewport/viewport_grid_generator/samples/small_grid.json @@ -0,0 +1,16 @@ +{ + "input": { + "viewport_state": { + "bounds": [ + -1.0, + -1.0, + 1.0, + 1.0 + ] + }, + "lat_interval": 0.25, + "lon_interval": 0.4 + }, + "expectedOutput": null, + "baseline_error": "Object of type ShowTextCommand is not JSON serializable" +} \ No newline at end of file diff --git a/apps/tools-mcp/test_server.py b/apps/tools-mcp/test_server.py new file mode 100644 index 00000000..ae60d6b2 --- /dev/null +++ b/apps/tools-mcp/test_server.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python3 +"""Simple script to test the MCP server.""" + +import asyncio +import sys +from pathlib import Path + +# Add src to path +sys.path.insert(0, str(Path(__file__).parent / "src")) + +from tools_mcp.server import mcp + + +async def main(): + """Test the MCP server by listing tools.""" + print("Testing MCP Server...") + print(f"\nServer name: {mcp.name}") + + # Get list of registered tools + tools = mcp._tool_manager._tools + print(f"\nRegistered {len(tools)} tools:") + for tool_name in tools.keys(): + print(f" - {tool_name}") + + # Test calling a simple tool + print("\n\nTesting word_count tool...") + try: + # Get the tool + word_count_tool = tools.get("text_word_count") + if word_count_tool: + print(f"Found tool: {word_count_tool.name}") + print(f"Description: {word_count_tool.description}") + + # Test execution + from tools_mcp.tools.text.word_count.execute import WordCountParameters + test_params = WordCountParameters(text="Hello world test") + result = word_count_tool.fn(test_params) + print(f"Test result: {result}") + else: + print("word_count tool not found") + except Exception as e: + print(f"Error testing tool: {e}") + import traceback + traceback.print_exc() + + +if __name__ == "__main__": + asyncio.run(main()) From 5fab275fa666104d3793d26dad9ea1986c1a8a87 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Nov 2025 20:29:58 +0000 Subject: [PATCH 2/4] feat: Add MCP client service for Tools MCP server integration Create new ToolsMcpClient service to connect to the pure FastMCP tools server: - HTTP-based MCP client using JSON-RPC 2.0 protocol - Tool listing via tools/list method - Tool execution via tools/call method - Converts MCP tool responses to DebriefCommand format - Compatible with legacy ToolRegistry format for outline view - Health check and connection management - Comprehensive error handling and logging This enables VS Code extension to use the new pure FastMCP server (apps/tools-mcp) instead of the legacy REST-based tool-vault server. Related to #236 --- apps/vs-code/src/services/toolsMcpClient.ts | 304 ++++++++++++++++++++ 1 file changed, 304 insertions(+) create mode 100644 apps/vs-code/src/services/toolsMcpClient.ts diff --git a/apps/vs-code/src/services/toolsMcpClient.ts b/apps/vs-code/src/services/toolsMcpClient.ts new file mode 100644 index 00000000..e5a5c586 --- /dev/null +++ b/apps/vs-code/src/services/toolsMcpClient.ts @@ -0,0 +1,304 @@ +/** + * Tools MCP Client Service + * + * Connects to the pure FastMCP tools server for maritime analysis tool execution. + * Uses the MCP protocol to list tools and execute them. + */ + +import * as vscode from 'vscode'; +import { ToolSchema } from '@debrief/web-components/services'; + +export interface McpTool { + name: string; + description?: string; + inputSchema?: { + type: string; + properties?: Record; + required?: string[]; + }; +} + +export interface McpToolListResponse { + tools: McpTool[]; +} + +export interface McpToolCallRequest { + name: string; + arguments: Record; +} + +export interface McpToolCallResponse { + content: Array<{ + type: string; + text?: string; + }>; + isError?: boolean; +} + +export class ToolsMcpClient { + private static instance: ToolsMcpClient; + private config: { + url: string; + } | null = null; + private outputChannel: vscode.OutputChannel; + private mcpClient: any = null; + private isConnected = false; + + private constructor() { + this.outputChannel = vscode.window.createOutputChannel('Tools MCP Client'); + } + + static getInstance(): ToolsMcpClient { + if (!ToolsMcpClient.instance) { + ToolsMcpClient.instance = new ToolsMcpClient(); + } + return ToolsMcpClient.instance; + } + + /** + * Connect to the MCP server + */ + async connect(url?: string): Promise { + const mcpUrl = url || 'http://localhost:8000'; + + this.config = { url: mcpUrl }; + this.log(`Connecting to Tools MCP server at ${mcpUrl}`); + + try { + // Test connection with a simple fetch + const healthUrl = `${mcpUrl}/health`; + const response = await fetch(healthUrl); + + if (!response.ok) { + throw new Error(`Health check failed: ${response.status} ${response.statusText}`); + } + + const health = await response.text(); + this.log(`Health check passed: ${health}`); + this.isConnected = true; + this.log('Successfully connected to Tools MCP server'); + + } catch (error) { + const message = `Failed to connect to Tools MCP server: ${error instanceof Error ? error.message : String(error)}`; + this.log(message); + this.isConnected = false; + throw new Error(message); + } + } + + /** + * Get the list of available tools from the MCP server + */ + async getToolList(): Promise { + if (!this.isConnected || !this.config) { + throw new Error('Not connected to Tools MCP server'); + } + + try { + this.log('Fetching tool list from MCP server'); + + // MCP uses JSON-RPC 2.0 over HTTP/SSE + // For listing tools, we use the tools/list method + const request = { + jsonrpc: '2.0', + id: Date.now(), + method: 'tools/list', + params: {} + }; + + const response = await fetch(`${this.config.url}/message`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(request) + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + const data = await response.json(); + + if (data.error) { + throw new Error(`MCP Error: ${data.error.message || JSON.stringify(data.error)}`); + } + + if (!data.result || !Array.isArray(data.result.tools)) { + throw new Error('Invalid response format from MCP server'); + } + + const tools = data.result.tools as McpTool[]; + this.log(`Retrieved ${tools.length} tools from MCP server`); + + return tools; + + } catch (error) { + const message = `Failed to get tool list: ${error instanceof Error ? error.message : String(error)}`; + this.log(message); + throw new Error(message); + } + } + + /** + * Execute a tool on the MCP server + */ + async executeTool(toolName: string, parameters: Record): Promise<{ success: boolean; result?: unknown; error?: string }> { + if (!this.isConnected || !this.config) { + return { + success: false, + error: 'Not connected to Tools MCP server' + }; + } + + try { + this.log(`Executing tool: ${toolName}`); + this.log(`Parameters: ${JSON.stringify(parameters, null, 2)}`); + + // MCP uses JSON-RPC 2.0 for tool calls + const request = { + jsonrpc: '2.0', + id: Date.now(), + method: 'tools/call', + params: { + name: toolName, + arguments: parameters + } + }; + + const response = await fetch(`${this.config.url}/message`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(request) + }); + + if (!response.ok) { + const errorText = await response.text(); + this.log(`HTTP Error: ${response.status} - ${errorText}`); + return { + success: false, + error: `HTTP ${response.status}: ${response.statusText}` + }; + } + + const data = await response.json(); + + if (data.error) { + const errorMessage = data.error.message || JSON.stringify(data.error); + this.log(`MCP Error: ${errorMessage}`); + return { + success: false, + error: errorMessage + }; + } + + // MCP tool responses have a result with content array + if (!data.result || !Array.isArray(data.result.content)) { + this.log(`Unexpected response format: ${JSON.stringify(data)}`); + return { + success: false, + error: 'Invalid response format from MCP server' + }; + } + + // Extract the result from the content array + // The content is typically [{ type: 'text', text: '' }] + const content = data.result.content; + let result: unknown; + + if (content.length > 0 && content[0].text) { + try { + // Try to parse as JSON (DebriefCommand format) + result = JSON.parse(content[0].text); + } catch { + // If not JSON, use as-is + result = { command: 'showText', payload: content[0].text }; + } + } else { + result = { command: 'showText', payload: 'No result' }; + } + + this.log(`Tool executed successfully: ${JSON.stringify(result, null, 2)}`); + + return { + success: true, + result + }; + + } catch (error) { + const errorMessage = `Failed to execute tool "${toolName}": ${error instanceof Error ? error.message : String(error)}`; + this.log(`Error: ${errorMessage}`); + return { + success: false, + error: errorMessage + }; + } + } + + /** + * Convert MCP tools to ToolSchema format for compatibility + */ + convertToToolIndex(mcpTools: McpTool[]): unknown { + // Convert MCP tools to the legacy ToolRegistry format + // The outline view expects a hierarchical structure with categories + + const toolsByCategory = new Map(); + + // Group tools by category (extracted from name prefix) + for (const tool of mcpTools) { + const parts = tool.name.split('_'); + const category = parts[0] || 'general'; + + if (!toolsByCategory.has(category)) { + toolsByCategory.set(category, []); + } + toolsByCategory.get(category)!.push(tool); + } + + // Build hierarchical structure + const root: unknown[] = []; + + for (const [category, tools] of toolsByCategory.entries()) { + const children = tools.map(tool => ({ + type: 'tool', + name: tool.name, + description: tool.description || '', + parameters: tool.inputSchema || { type: 'object', properties: {} } + })); + + root.push({ + type: 'category', + name: category, + children + }); + } + + return { root }; + } + + /** + * Check if client is connected + */ + isRunning(): boolean { + return this.isConnected; + } + + /** + * Disconnect from the MCP server + */ + async disconnect(): Promise { + this.isConnected = false; + this.config = null; + this.log('Disconnected from Tools MCP server'); + } + + /** + * Log a message to the output channel + */ + private log(message: string): void { + const timestamp = new Date().toISOString(); + this.outputChannel.appendLine(`[${timestamp}] ${message}`); + } +} From 8c74deb0c47bb896b9af16bb5e2e5e69ab11f423 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Nov 2025 20:34:49 +0000 Subject: [PATCH 3/4] feat: Integrate MCP client in VS Code extension with ToolVault fallback This commit completes the VS Code extension integration with the new Tools MCP server, providing a modern alternative to the legacy REST-based Tool Vault server. Changes: - Added ToolsMcpClient service (src/services/toolsMcpClient.ts) * JSON-RPC 2.0 over HTTP for MCP communication * Tool listing via tools/list method * Tool execution via tools/call method * Converts MCP tool format to ToolVault-compatible structure - Modified GlobalController (src/core/globalController.ts) * Added MCP client integration with initializeToolsMcpClient() * Prefer MCP for getToolIndex() with ToolVault fallback * Prefer MCP for executeTool() with ToolVault fallback * Maintains backwards compatibility with existing code - Updated extension activation (src/extension.ts) * Initialize MCP client if debrief.toolsMcp.serverUrl is configured * Connect to MCP server asynchronously on startup * Notify GlobalController when MCP client is ready * Fall back to ToolVault if MCP connection fails - Added configuration (package.json) * New setting: debrief.toolsMcp.serverUrl * Users can specify MCP server URL (e.g., http://localhost:8000) * If set, MCP takes precedence over ToolVault - Added test utilities (test-mcp-protocol.js) * Documents MCP JSON-RPC 2.0 protocol format * Example requests for tools/list and tools/call Architecture Benefits: - Clean separation: MCP client handles protocol, GlobalController handles state - Fallback mechanism: Legacy ToolVault continues to work if MCP unavailable - Zero breaking changes: Existing code paths remain functional - Configuration-driven: Users can opt-in to MCP via settings Related to: #236 (Pure FastMCP package implementation) --- apps/vs-code/package.json | 5 ++ apps/vs-code/src/core/globalController.ts | 69 +++++++++++++++++++---- apps/vs-code/src/extension.ts | 27 ++++++++- apps/vs-code/test-mcp-protocol.js | 59 +++++++++++++++++++ 4 files changed, 148 insertions(+), 12 deletions(-) create mode 100644 apps/vs-code/test-mcp-protocol.js diff --git a/apps/vs-code/package.json b/apps/vs-code/package.json index 9058e840..73703e86 100644 --- a/apps/vs-code/package.json +++ b/apps/vs-code/package.json @@ -158,6 +158,11 @@ "description": "Host address for the Tool Vault server", "scope": "resource" }, + "debrief.toolsMcp.serverUrl": { + "type": "string", + "description": "URL for the Tools MCP server (e.g., http://localhost:8000). If set, this will be used instead of the legacy Tool Vault server.", + "scope": "resource" + }, "debrief.development.mode": { "type": "boolean", "default": false, diff --git a/apps/vs-code/src/core/globalController.ts b/apps/vs-code/src/core/globalController.ts index 27b4dfb9..bcca7ef3 100644 --- a/apps/vs-code/src/core/globalController.ts +++ b/apps/vs-code/src/core/globalController.ts @@ -2,6 +2,7 @@ import * as vscode from 'vscode'; import { TimeState, ViewportState, SelectionState, DebriefFeatureCollection, EditorState, DebriefFeature } from '@debrief/shared-types'; import { ToolVaultServerService } from '../services/toolVaultServer'; +import { ToolsMcpClient } from '../services/toolsMcpClient'; import { ToolParameterService, DebriefCommandHandler, @@ -56,9 +57,12 @@ export class GlobalController implements StateProvider { private _onDidChangeState = new vscode.EventEmitter<{ editorId: string; eventType: StateEventType; state: EditorState }>(); public readonly onDidChangeState = this._onDidChangeState.event; - // Tool Vault Server integration + // Tool Vault Server integration (legacy) private toolVaultServer?: ToolVaultServerService; + // Tools MCP Client integration (modern) + private toolsMcpClient?: ToolsMcpClient; + // Tool Parameter Service for automatic parameter injection private toolParameterService: ToolParameterService; @@ -331,7 +335,7 @@ export class GlobalController implements StateProvider { } /** - * Initialize Tool Vault Server integration + * Initialize Tool Vault Server integration (legacy) */ public initializeToolVaultServer(toolVaultServer: ToolVaultServerService): void { console.warn('[GlobalController] Initializing Tool Vault server:', !!toolVaultServer); @@ -339,6 +343,15 @@ export class GlobalController implements StateProvider { console.warn('[GlobalController] Tool Vault server initialized successfully'); } + /** + * Initialize Tools MCP Client integration (modern) + */ + public initializeToolsMcpClient(toolsMcpClient: ToolsMcpClient): void { + console.warn('[GlobalController] Initializing Tools MCP client:', !!toolsMcpClient); + this.toolsMcpClient = toolsMcpClient; + console.warn('[GlobalController] Tools MCP client initialized successfully'); + } + /** * Notify that the Tool Vault server is ready and available */ @@ -359,35 +372,69 @@ export class GlobalController implements StateProvider { } /** - * Get the tool index from the Tool Vault Server + * Get the tool index from either MCP client or Tool Vault Server + * Prefers MCP client if available */ public async getToolIndex(): Promise { - console.warn('[GlobalController] getToolIndex called - toolVaultServer present:', !!this.toolVaultServer); + console.warn('[GlobalController] getToolIndex called - MCP client:', !!this.toolsMcpClient, 'ToolVault:', !!this.toolVaultServer); + + // Prefer MCP client if available + if (this.toolsMcpClient && this.toolsMcpClient.isRunning()) { + console.warn('[GlobalController] Using Tools MCP client for tool index'); + try { + const mcpTools = await this.toolsMcpClient.getToolList(); + return this.toolsMcpClient.convertToToolIndex(mcpTools); + } catch (error) { + console.error('[GlobalController] MCP client failed, falling back to ToolVault:', error); + // Fall through to ToolVault + } + } + // Fall back to legacy ToolVault server if (!this.toolVaultServer) { - console.error('[GlobalController] Tool Vault server is not initialized'); - throw new Error('Tool Vault server is not initialized'); + console.error('[GlobalController] Neither MCP client nor Tool Vault server is initialized'); + throw new Error('No tool server is available'); } - console.warn('[GlobalController] Calling toolVaultServer.getToolIndex()'); + console.warn('[GlobalController] Using ToolVault server for tool index'); return this.toolVaultServer.getToolIndex(); } /** * Execute a tool command with the given parameters + * Prefers MCP client if available */ public async executeTool(toolName: string, parameters: Record): Promise<{ success: boolean; result?: unknown; error?: string }> { - console.warn('[GlobalController] executeTool called - toolVaultServer present:', !!this.toolVaultServer); + console.warn('[GlobalController] executeTool called for:', toolName, '- MCP client:', !!this.toolsMcpClient, 'ToolVault:', !!this.toolVaultServer); + + // Prefer MCP client if available + if (this.toolsMcpClient && this.toolsMcpClient.isRunning()) { + console.warn('[GlobalController] Using Tools MCP client for execution'); + try { + const result = await this.toolsMcpClient.executeTool(toolName, parameters); + + // If the tool returned DebriefCommands, process them + if (result.success && result.result) { + await this.processDebriefCommands(result.result); + } + + return result; + } catch (error) { + console.error('[GlobalController] MCP client execution failed, falling back to ToolVault:', error); + // Fall through to ToolVault + } + } + // Fall back to legacy ToolVault server if (!this.toolVaultServer) { - console.error('[GlobalController] Tool Vault server is not initialized in executeTool'); + console.error('[GlobalController] Neither MCP client nor Tool Vault server is initialized in executeTool'); return { success: false, - error: 'Tool Vault server is not initialized' + error: 'No tool server is available' }; } - console.warn('[GlobalController] Calling toolVaultServer.executeToolCommand for:', toolName); + console.warn('[GlobalController] Using ToolVault server for execution'); const result = await this.toolVaultServer.executeToolCommand(toolName, parameters); // If the tool returned DebriefCommands, process them diff --git a/apps/vs-code/src/extension.ts b/apps/vs-code/src/extension.ts index 570e5117..ff927dfb 100644 --- a/apps/vs-code/src/extension.ts +++ b/apps/vs-code/src/extension.ts @@ -16,6 +16,7 @@ import { DebriefMcpServer } from './services/debriefMcpServer'; import { PythonWheelInstaller } from './services/pythonWheelInstaller'; import { ToolVaultServerService } from './services/toolVaultServer'; import { ToolVaultConfigService } from './services/toolVaultConfig'; +import { ToolsMcpClient } from './services/toolsMcpClient'; // Server status indicators import { ServerStatusBarIndicator } from './components/ServerStatusBarIndicator'; @@ -27,6 +28,7 @@ let activationHandler: EditorActivationHandler | null = null; let statePersistence: StatePersistence | null = null; let historyManager: HistoryManager | null = null; let toolVaultServer: ToolVaultServerService | null = null; +let toolsMcpClient: ToolsMcpClient | null = null; // Store reference to the consolidated Debrief activity panel provider let debriefActivityProvider: DebriefActivityProvider | null = null; @@ -226,7 +228,7 @@ export function activate(context: vscode.ExtensionContext) { globalController = GlobalController.getInstance(); context.subscriptions.push(globalController); - // Initialize Tool Vault Server integration in GlobalController + // Initialize Tool Vault Server integration in GlobalController (legacy) if (toolVaultServer) { globalController.initializeToolVaultServer(toolVaultServer); @@ -241,6 +243,29 @@ export function activate(context: vscode.ExtensionContext) { } }); } + + // Initialize Tools MCP Client integration (modern) + // Check if MCP server URL is configured + const mcpServerUrl = vscode.workspace.getConfiguration('debrief').get('toolsMcp.serverUrl'); + if (mcpServerUrl) { + console.warn('[Extension] Initializing Tools MCP client with URL:', mcpServerUrl); + toolsMcpClient = ToolsMcpClient.getInstance(); + + // Connect to MCP server asynchronously + toolsMcpClient.connect(mcpServerUrl).then(() => { + console.warn('[Extension] Tools MCP client connected successfully'); + if (globalController && toolsMcpClient) { + globalController.initializeToolsMcpClient(toolsMcpClient); + // Notify that tools are ready (reuse same event as ToolVault) + globalController.notifyToolVaultReady(); + } + }).catch((error: unknown) => { + console.error('[Extension] Failed to connect Tools MCP client:', error); + // Non-fatal - extension continues with ToolVault fallback + }); + } else { + console.warn('[Extension] No Tools MCP server URL configured - using ToolVault fallback'); + } // Initialize Editor Activation Handler activationHandler = new EditorActivationHandler(globalController); diff --git a/apps/vs-code/test-mcp-protocol.js b/apps/vs-code/test-mcp-protocol.js new file mode 100644 index 00000000..a240caa2 --- /dev/null +++ b/apps/vs-code/test-mcp-protocol.js @@ -0,0 +1,59 @@ +/** + * Test script to understand MCP protocol for calling tools + */ + +// Test calling the FastMCP server to understand the protocol +async function testMCPProtocol() { + const baseUrl = 'http://21.0.0.58:8000'; + + console.log('Testing MCP Protocol...\n'); + + // Test 1: List tools + console.log('1. Listing tools via GET /sse...'); + try { + // MCP uses Server-Sent Events (SSE) for communication + // The MCP protocol uses JSON-RPC 2.0 over SSE + + // For SSE, we need to establish an EventSource connection + // But for testing, let's try the HTTP approach first + + const response = await fetch(`${baseUrl}/sse`); + console.log('Status:', response.status); + console.log('Headers:', Object.fromEntries(response.headers.entries())); + + const text = await response.text(); + console.log('Response (first 500 chars):', text.substring(0, 500)); + + } catch (error) { + console.error('Error:', error); + } + + // Test 2: Understanding MCP JSON-RPC format + console.log('\n2. Understanding MCP JSON-RPC format...'); + console.log('MCP uses JSON-RPC 2.0 protocol'); + console.log('Example list_tools request:'); + const listToolsRequest = { + jsonrpc: '2.0', + id: 1, + method: 'tools/list', + params: {} + }; + console.log(JSON.stringify(listToolsRequest, null, 2)); + + console.log('\nExample call_tool request:'); + const callToolRequest = { + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { + name: 'text_word_count', + arguments: { + text: 'Hello world test' + } + } + }; + console.log(JSON.stringify(callToolRequest, null, 2)); +} + +// Run the test +testMCPProtocol().catch(console.error); From 0506389b7583ae7164358304859176432f32a496 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Nov 2025 16:18:26 +0000 Subject: [PATCH 4/4] refactor: Remove legacy ToolVault integration from VS Code extension Remove all ToolVault-related code and make ToolsMcpClient the sole tool provider. This eliminates the legacy tool-vault-packager dependency and simplifies the tool integration architecture. Changes: - Remove ToolVault server service and configuration files - Remove ToolVault status bar indicator and commands from extension.ts - Remove ToolVault fallback logic from globalController.ts - Rename 'toolVaultReady' event to 'toolsReady' throughout - Update debriefActivityProvider to use new 'toolsReady' event - Remove ToolVault-related configuration from package.json - Remove ToolVault build scripts from package.json - Remove createToolVaultConfig from serverIndicatorConfigs.ts The extension now requires debrief.toolsMcp.serverUrl to be configured for tool functionality. Users will see clear error messages if not configured. --- apps/vs-code/package.json | 40 +- .../src/config/serverIndicatorConfigs.ts | 47 -- apps/vs-code/src/core/globalController.ts | 97 +-- apps/vs-code/src/extension.ts | 157 +---- .../panels/debriefActivityProvider.ts | 14 +- apps/vs-code/src/services/toolVaultConfig.ts | 151 ----- apps/vs-code/src/services/toolVaultServer.ts | 626 ------------------ 7 files changed, 48 insertions(+), 1084 deletions(-) delete mode 100644 apps/vs-code/src/services/toolVaultConfig.ts delete mode 100644 apps/vs-code/src/services/toolVaultServer.ts diff --git a/apps/vs-code/package.json b/apps/vs-code/package.json index 73703e86..0025d510 100644 --- a/apps/vs-code/package.json +++ b/apps/vs-code/package.json @@ -9,19 +9,18 @@ "url": "https://github.com/debrief/future-debrief/apps/vs-code.git" }, "scripts": { - "clean": "rm -rf dist/* media/web-components.js media/web-components.css python/ schemas/ tool-vault/", + "clean": "rm -rf dist/* media/web-components.js media/web-components.css python/ schemas/", "esbuild-base": "esbuild ./src/extension.ts --bundle --outfile=dist/extension.js --external:vscode --external:@valibot/to-json-schema --external:sury --external:effect --format=cjs --platform=node --define:import.meta.url='__import_meta_url__' --banner:js=\"const __import_meta_url__ = (function() { try { if (typeof __filename !== 'undefined') { return require('url').pathToFileURL(__filename).href; } } catch (e) { console.error('[Polyfill Error]', e); } return 'file:///extension.js'; })();\"", "generate-mcp-tools": "node scripts/generate-mcp-tools.js", "copy-webcomponents": "cp ../../libs/web-components/dist/vanilla/index.js media/web-components.js && cp ../../libs/web-components/dist/vanilla/index.css media/web-components.css", "copy-schemas": "mkdir -p schemas && cp -r ../../libs/shared-types/derived/json-schema/* schemas/", "bundle-python-wheel": "mkdir -p python && cp ../../libs/shared-types/dist/python/*.whl python/ 2>/dev/null || echo 'No Python wheel found - run pnpm build:shared-types first'", - "copy-toolvault": "mkdir -p tool-vault && cp ../../libs/tool-vault-packager/dist/toolvault.pyz tool-vault/toolvault.pyz 2>/dev/null || echo 'No toolvault.pyz found - run: cd libs/tool-vault-packager && npm run build'", - "compile": "pnpm generate-mcp-tools && pnpm esbuild-base --sourcemap && pnpm copy-webcomponents && pnpm copy-schemas && pnpm bundle-python-wheel && pnpm copy-toolvault", + "compile": "pnpm generate-mcp-tools && pnpm esbuild-base --sourcemap && pnpm copy-webcomponents && pnpm copy-schemas && pnpm bundle-python-wheel", "build": "pnpm vscode:prepublish && vsce package --no-dependencies", "build:docker": "pnpm build && cp vs-code-0.0.1.vsix ../../", "watch": "pnpm esbuild-base --sourcemap --watch", "dev": "pnpm copy-webcomponents && pnpm watch", - "vscode:prepublish": "pnpm generate-mcp-tools && pnpm esbuild-base --minify && pnpm copy-webcomponents && pnpm copy-schemas && pnpm bundle-python-wheel && pnpm copy-toolvault", + "vscode:prepublish": "pnpm generate-mcp-tools && pnpm esbuild-base --minify && pnpm copy-webcomponents && pnpm copy-schemas && pnpm bundle-python-wheel", "docker:build": "docker build -t debrief-vscode-local --build-arg GITHUB_SHA=local --build-arg PR_NUMBER=dev -f Dockerfile ../..", "docker:run": "docker run -p 8080:8080 -p 60123:60123 -p 60124:60124 debrief-vscode-local", "docker:stop": "docker stop debrief-vscode-local && docker rm debrief-vscode-local", @@ -74,18 +73,10 @@ "command": "debrief.redo", "title": "Debrief: Redo" }, - { - "command": "debrief.restartToolVault", - "title": "Restart Tool Vault Server" - }, { "command": "debrief.toggleDevelopmentMode", "title": "Debrief: Toggle Development Mode" }, - { - "command": "debrief.toolVaultStatus", - "title": "Debrief: Tool Vault Server Status" - }, { "command": "debrief.viewLastToolResult", "title": "Debrief: View Last Tool Result" @@ -135,32 +126,9 @@ "configuration": { "title": "Debrief", "properties": { - "debrief.toolVault.serverPath": { - "type": "string", - "description": "Path to the Tool Vault .pyz file", - "scope": "resource" - }, - "debrief.toolVault.autoStart": { - "type": "boolean", - "default": true, - "description": "Automatically start the Tool Vault server on extension activation", - "scope": "resource" - }, - "debrief.toolVault.port": { - "type": "number", - "default": 60124, - "description": "Port for the Tool Vault server", - "scope": "resource" - }, - "debrief.toolVault.host": { - "type": "string", - "default": "127.0.0.1", - "description": "Host address for the Tool Vault server", - "scope": "resource" - }, "debrief.toolsMcp.serverUrl": { "type": "string", - "description": "URL for the Tools MCP server (e.g., http://localhost:8000). If set, this will be used instead of the legacy Tool Vault server.", + "description": "URL for the Tools MCP server (e.g., http://localhost:8000). Required for tool functionality.", "scope": "resource" }, "debrief.development.mode": { diff --git a/apps/vs-code/src/config/serverIndicatorConfigs.ts b/apps/vs-code/src/config/serverIndicatorConfigs.ts index 0ee046d8..7c3ee757 100644 --- a/apps/vs-code/src/config/serverIndicatorConfigs.ts +++ b/apps/vs-code/src/config/serverIndicatorConfigs.ts @@ -1,7 +1,6 @@ import * as vscode from 'vscode'; import { ServerIndicatorConfig } from '../types/ServerIndicatorConfig'; import { DebriefMcpServer } from '../services/debriefMcpServer'; -import { ToolVaultServerService } from '../services/toolVaultServer'; /** * Creates configuration for Debrief MCP Server status indicator. @@ -50,52 +49,6 @@ export function createDebriefHttpConfig( }; } -/** - * Creates configuration for Tool Vault Server status indicator. - * - * The Tool Vault server provides MCP-compatible REST API for Python tools - * on port 60124. It runs as a Python subprocess managed by ToolVaultServerService. - * - * @returns ServerIndicatorConfig for Tool Vault server - * - * @example - * ```typescript - * const config = createToolVaultConfig(); - * ``` - */ -export function createToolVaultConfig(): ServerIndicatorConfig { - return { - name: 'Tool Vault', - healthCheckUrl: 'http://localhost:60124/health', - pollInterval: 5000, - - onStart: async () => { - const service = ToolVaultServerService.getInstance(); - await service.startServer(); - }, - - onStop: async () => { - const service = ToolVaultServerService.getInstance(); - await service.stopServer(); - }, - - onRestart: async () => { - const service = ToolVaultServerService.getInstance(); - await service.restartServer(); - }, - - onOpenWebUI: () => { - // Open Tool Vault web interface in external browser - vscode.env.openExternal(vscode.Uri.parse('http://localhost:60124/ui')); - }, - - onShowDetails: () => { - // Show existing Tool Vault status command - vscode.commands.executeCommand('debrief.toolVaultStatus'); - } - }; -} - /** * Validates server configurations at runtime. * diff --git a/apps/vs-code/src/core/globalController.ts b/apps/vs-code/src/core/globalController.ts index bcca7ef3..706f352c 100644 --- a/apps/vs-code/src/core/globalController.ts +++ b/apps/vs-code/src/core/globalController.ts @@ -1,7 +1,6 @@ import * as vscode from 'vscode'; import { TimeState, ViewportState, SelectionState, DebriefFeatureCollection, EditorState, DebriefFeature } from '@debrief/shared-types'; -import { ToolVaultServerService } from '../services/toolVaultServer'; import { ToolsMcpClient } from '../services/toolsMcpClient'; import { ToolParameterService, @@ -16,7 +15,7 @@ export { EditorState }; // Event types for the GlobalController -export type StateEventType = 'fcChanged' | 'timeChanged' | 'viewportChanged' | 'selectionChanged' | 'activeEditorChanged' | 'toolVaultReady'; +export type StateEventType = 'fcChanged' | 'timeChanged' | 'viewportChanged' | 'selectionChanged' | 'activeEditorChanged' | 'toolsReady'; export type StateSliceType = 'featureCollection' | 'timeState' | 'viewportState' | 'selectionState'; @@ -57,10 +56,7 @@ export class GlobalController implements StateProvider { private _onDidChangeState = new vscode.EventEmitter<{ editorId: string; eventType: StateEventType; state: EditorState }>(); public readonly onDidChangeState = this._onDidChangeState.event; - // Tool Vault Server integration (legacy) - private toolVaultServer?: ToolVaultServerService; - - // Tools MCP Client integration (modern) + // Tools MCP Client integration private toolsMcpClient?: ToolsMcpClient; // Tool Parameter Service for automatic parameter injection @@ -89,7 +85,7 @@ export class GlobalController implements StateProvider { * Initialize event handler storage */ private initializeEventHandlers(): void { - const eventTypes: StateEventType[] = ['fcChanged', 'timeChanged', 'viewportChanged', 'selectionChanged', 'activeEditorChanged', 'toolVaultReady']; + const eventTypes: StateEventType[] = ['fcChanged', 'timeChanged', 'viewportChanged', 'selectionChanged', 'activeEditorChanged', 'toolsReady']; eventTypes.forEach(eventType => { this.eventHandlers.set(eventType, new Set()); }); @@ -335,16 +331,7 @@ export class GlobalController implements StateProvider { } /** - * Initialize Tool Vault Server integration (legacy) - */ - public initializeToolVaultServer(toolVaultServer: ToolVaultServerService): void { - console.warn('[GlobalController] Initializing Tool Vault server:', !!toolVaultServer); - this.toolVaultServer = toolVaultServer; - console.warn('[GlobalController] Tool Vault server initialized successfully'); - } - - /** - * Initialize Tools MCP Client integration (modern) + * Initialize Tools MCP Client integration */ public initializeToolsMcpClient(toolsMcpClient: ToolsMcpClient): void { console.warn('[GlobalController] Initializing Tools MCP client:', !!toolsMcpClient); @@ -353,89 +340,56 @@ export class GlobalController implements StateProvider { } /** - * Notify that the Tool Vault server is ready and available + * Notify that the tools are ready and available */ - public notifyToolVaultReady(): void { - console.warn('[GlobalController] Tool Vault server ready - notifying subscribers'); - const handlers = this.eventHandlers.get('toolVaultReady'); - console.warn('[GlobalController] Found', handlers?.size || 0, 'toolVaultReady handlers'); + public notifyToolsReady(): void { + console.warn('[GlobalController] Tools ready - notifying subscribers'); + const handlers = this.eventHandlers.get('toolsReady'); + console.warn('[GlobalController] Found', handlers?.size || 0, 'toolsReady handlers'); if (handlers) { handlers.forEach(handler => { try { - console.warn('[GlobalController] Calling toolVaultReady handler'); + console.warn('[GlobalController] Calling toolsReady handler'); (handler as () => void)(); } catch (error) { - console.error('[GlobalController] Error in toolVaultReady handler:', error); + console.error('[GlobalController] Error in toolsReady handler:', error); } }); } } /** - * Get the tool index from either MCP client or Tool Vault Server - * Prefers MCP client if available + * Get the tool index from Tools MCP Client */ public async getToolIndex(): Promise { - console.warn('[GlobalController] getToolIndex called - MCP client:', !!this.toolsMcpClient, 'ToolVault:', !!this.toolVaultServer); - - // Prefer MCP client if available - if (this.toolsMcpClient && this.toolsMcpClient.isRunning()) { - console.warn('[GlobalController] Using Tools MCP client for tool index'); - try { - const mcpTools = await this.toolsMcpClient.getToolList(); - return this.toolsMcpClient.convertToToolIndex(mcpTools); - } catch (error) { - console.error('[GlobalController] MCP client failed, falling back to ToolVault:', error); - // Fall through to ToolVault - } - } + console.warn('[GlobalController] getToolIndex called - MCP client:', !!this.toolsMcpClient); - // Fall back to legacy ToolVault server - if (!this.toolVaultServer) { - console.error('[GlobalController] Neither MCP client nor Tool Vault server is initialized'); - throw new Error('No tool server is available'); + if (!this.toolsMcpClient || !this.toolsMcpClient.isRunning()) { + console.error('[GlobalController] Tools MCP client is not initialized or not running'); + throw new Error('Tools MCP client is not available. Please configure debrief.toolsMcp.serverUrl in settings.'); } - console.warn('[GlobalController] Using ToolVault server for tool index'); - return this.toolVaultServer.getToolIndex(); + console.warn('[GlobalController] Using Tools MCP client for tool index'); + const mcpTools = await this.toolsMcpClient.getToolList(); + return this.toolsMcpClient.convertToToolIndex(mcpTools); } /** * Execute a tool command with the given parameters - * Prefers MCP client if available */ public async executeTool(toolName: string, parameters: Record): Promise<{ success: boolean; result?: unknown; error?: string }> { - console.warn('[GlobalController] executeTool called for:', toolName, '- MCP client:', !!this.toolsMcpClient, 'ToolVault:', !!this.toolVaultServer); - - // Prefer MCP client if available - if (this.toolsMcpClient && this.toolsMcpClient.isRunning()) { - console.warn('[GlobalController] Using Tools MCP client for execution'); - try { - const result = await this.toolsMcpClient.executeTool(toolName, parameters); - - // If the tool returned DebriefCommands, process them - if (result.success && result.result) { - await this.processDebriefCommands(result.result); - } - - return result; - } catch (error) { - console.error('[GlobalController] MCP client execution failed, falling back to ToolVault:', error); - // Fall through to ToolVault - } - } + console.warn('[GlobalController] executeTool called for:', toolName, '- MCP client:', !!this.toolsMcpClient); - // Fall back to legacy ToolVault server - if (!this.toolVaultServer) { - console.error('[GlobalController] Neither MCP client nor Tool Vault server is initialized in executeTool'); + if (!this.toolsMcpClient || !this.toolsMcpClient.isRunning()) { + console.error('[GlobalController] Tools MCP client is not initialized or not running'); return { success: false, - error: 'No tool server is available' + error: 'Tools MCP client is not available. Please configure debrief.toolsMcp.serverUrl in settings.' }; } - console.warn('[GlobalController] Using ToolVault server for execution'); - const result = await this.toolVaultServer.executeToolCommand(toolName, parameters); + console.warn('[GlobalController] Using Tools MCP client for execution'); + const result = await this.toolsMcpClient.executeTool(toolName, parameters); // If the tool returned DebriefCommands, process them if (result.success && result.result) { @@ -829,6 +783,5 @@ ${error}`; this.eventHandlers.clear(); this.editorStates.clear(); this._activeEditorId = undefined; - this.toolVaultServer = undefined; } } \ No newline at end of file diff --git a/apps/vs-code/src/extension.ts b/apps/vs-code/src/extension.ts index ff927dfb..eef42f33 100644 --- a/apps/vs-code/src/extension.ts +++ b/apps/vs-code/src/extension.ts @@ -14,20 +14,17 @@ import { DebriefActivityProvider } from './providers/panels/debriefActivityProvi // External services import { DebriefMcpServer } from './services/debriefMcpServer'; import { PythonWheelInstaller } from './services/pythonWheelInstaller'; -import { ToolVaultServerService } from './services/toolVaultServer'; -import { ToolVaultConfigService } from './services/toolVaultConfig'; import { ToolsMcpClient } from './services/toolsMcpClient'; // Server status indicators import { ServerStatusBarIndicator } from './components/ServerStatusBarIndicator'; -import { createDebriefHttpConfig, createToolVaultConfig } from './config/serverIndicatorConfigs'; +import { createDebriefHttpConfig } from './config/serverIndicatorConfigs'; let mcpServer: DebriefMcpServer | null = null; let globalController: GlobalController | null = null; let activationHandler: EditorActivationHandler | null = null; let statePersistence: StatePersistence | null = null; let historyManager: HistoryManager | null = null; -let toolVaultServer: ToolVaultServerService | null = null; let toolsMcpClient: ToolsMcpClient | null = null; // Store reference to the consolidated Debrief activity panel provider @@ -47,11 +44,8 @@ export function activate(context: vscode.ExtensionContext) { // This is expected if Python environment is not yet set up }); - // Initialize Tool Vault server - toolVaultServer = ToolVaultServerService.getInstance(); - - // Create server status bar indicators - console.warn('[Extension] Creating server status bar indicators...'); + // Create server status bar indicator for Debrief HTTP server + console.warn('[Extension] Creating server status bar indicator...'); try { const debriefHttpConfig = createDebriefHttpConfig( () => mcpServer, @@ -59,17 +53,11 @@ export function activate(context: vscode.ExtensionContext) { ); console.warn('[Extension] Debrief HTTP config created'); - const toolVaultConfig = createToolVaultConfig(); - console.warn('[Extension] Tool Vault config created'); - const debriefIndicator = new ServerStatusBarIndicator(debriefHttpConfig, 100); console.warn('[Extension] Debrief HTTP indicator created'); - const toolVaultIndicator = new ServerStatusBarIndicator(toolVaultConfig, 99); - console.warn('[Extension] Tool Vault indicator created'); - - context.subscriptions.push(debriefIndicator, toolVaultIndicator); - console.warn('[Extension] Status bar indicators registered successfully'); + context.subscriptions.push(debriefIndicator); + console.warn('[Extension] Status bar indicator registered successfully'); // Auto-start Debrief HTTP Server (silently, no notification) // Status bar indicator will show the state @@ -78,19 +66,8 @@ export function activate(context: vscode.ExtensionContext) { console.error('[Extension] Failed to auto-start Debrief HTTP Server:', error); // Error is already shown via status bar indicator error handling }); - - // Auto-start Tool Vault Server if configured (silently, no notification) - const configService = ToolVaultConfigService.getInstance(); - const tvConfig = configService.getConfiguration(); - if (tvConfig && tvConfig.autoStart && tvConfig.serverPath) { - console.warn('[Extension] Auto-starting Tool Vault Server...'); - toolVaultIndicator.start().catch((error: unknown) => { - console.error('[Extension] Failed to auto-start Tool Vault Server:', error); - // Error is already shown via status bar indicator error handling - }); - } } catch (error) { - console.error('[Extension] Failed to create status bar indicators:', error); + console.error('[Extension] Failed to create status bar indicator:', error); } // Add cleanup to subscriptions @@ -101,11 +78,6 @@ export function activate(context: vscode.ExtensionContext) { console.error('Error stopping MCP server during cleanup:', error); }); } - if (toolVaultServer) { - toolVaultServer.stopServer().catch((error: unknown) => { - console.error('Error stopping Tool Vault server during cleanup:', error); - }); - } } }); @@ -124,30 +96,6 @@ export function activate(context: vscode.ExtensionContext) { }) ); - // Register Tool Vault server restart command - const restartToolVaultCommand = vscode.commands.registerCommand( - 'debrief.restartToolVault', - async () => { - try { - if (toolVaultServer) { - await toolVaultServer.restartServer(); - const toolIndex = await toolVaultServer.getToolIndex(); - const toolCount = (toolIndex as {tools?: unknown[]})?.tools?.length ?? 'unknown number of'; - vscode.window.showInformationMessage( - `Tool Vault server restarted successfully with ${toolCount} tools available.` - ); - } else { - vscode.window.showErrorMessage('Tool Vault server service is not initialized.'); - } - } catch (error) { - const errorMessage = `Failed to restart Tool Vault server: ${error instanceof Error ? error.message : String(error)}`; - console.error(errorMessage); - vscode.window.showErrorMessage(errorMessage); - } - } - ); - context.subscriptions.push(restartToolVaultCommand); - // Register development mode toggle command const toggleDevelopmentModeCommand = vscode.commands.registerCommand( 'debrief.toggleDevelopmentMode', @@ -170,81 +118,13 @@ export function activate(context: vscode.ExtensionContext) { ); context.subscriptions.push(toggleDevelopmentModeCommand); - // Register Tool Vault status command - const toolVaultStatusCommand = vscode.commands.registerCommand( - 'debrief.toolVaultStatus', - async () => { - try { - if (!toolVaultServer) { - vscode.window.showWarningMessage('Tool Vault server service is not initialized.'); - return; - } - - const isRunning = toolVaultServer.isRunning(); - const config = ToolVaultConfigService.getInstance().getConfiguration(); - - let statusMessage = `**Tool Vault Server Status**\n\n`; - statusMessage += `• **Running**: ${isRunning ? '✅ Yes' : '❌ No'}\n`; - statusMessage += `• **Auto-start**: ${config.autoStart ? '✅ Enabled' : '❌ Disabled'}\n`; - statusMessage += `• **Server Path**: ${config.serverPath || '❌ Not configured'}\n`; - statusMessage += `• **Host**: ${config.host}\n`; - statusMessage += `• **Port**: ${config.port}\n`; - - if (isRunning) { - try { - const healthCheck = await toolVaultServer.healthCheck(); - statusMessage += `• **Health Check**: ${healthCheck ? '✅ Healthy' : '❌ Unhealthy'}\n`; - - if (healthCheck) { - const toolIndex = await toolVaultServer.getToolIndex(); - const toolCount = (toolIndex as {tools?: unknown[]})?.tools?.length ?? 0; - statusMessage += `• **Tools Available**: ${toolCount}\n`; - } - } catch (error) { - statusMessage += `• **Health Check**: ❌ Failed (${error instanceof Error ? error.message : String(error)})\n`; - } - } - - // Show detailed status in a modal - vscode.window.showInformationMessage(statusMessage, { modal: true }, 'Restart Server', 'Configure Settings').then(selection => { - if (selection === 'Restart Server') { - vscode.commands.executeCommand('debrief.restartToolVault'); - } else if (selection === 'Configure Settings') { - vscode.commands.executeCommand('workbench.action.openSettings', 'debrief.toolVault'); - } - }); - } catch (error) { - const errorMessage = `Failed to get Tool Vault server status: ${error instanceof Error ? error.message : String(error)}`; - console.error(errorMessage); - vscode.window.showErrorMessage(errorMessage); - } - } - ); - context.subscriptions.push(toolVaultStatusCommand); - // Multi-select is now handled internally by the OutlineView webview component // Initialize Global Controller (new centralized state management) globalController = GlobalController.getInstance(); context.subscriptions.push(globalController); - // Initialize Tool Vault Server integration in GlobalController (legacy) - if (toolVaultServer) { - globalController.initializeToolVaultServer(toolVaultServer); - - // Set up callback to notify GlobalController when server is ready - toolVaultServer.setOnServerReadyCallback(() => { - console.warn('[Extension] Tool Vault server ready callback triggered'); - if (globalController) { - console.warn('[Extension] Calling globalController.notifyToolVaultReady()'); - globalController.notifyToolVaultReady(); - } else { - console.error('[Extension] GlobalController is null when server ready callback triggered'); - } - }); - } - - // Initialize Tools MCP Client integration (modern) + // Initialize Tools MCP Client integration // Check if MCP server URL is configured const mcpServerUrl = vscode.workspace.getConfiguration('debrief').get('toolsMcp.serverUrl'); if (mcpServerUrl) { @@ -256,15 +136,16 @@ export function activate(context: vscode.ExtensionContext) { console.warn('[Extension] Tools MCP client connected successfully'); if (globalController && toolsMcpClient) { globalController.initializeToolsMcpClient(toolsMcpClient); - // Notify that tools are ready (reuse same event as ToolVault) - globalController.notifyToolVaultReady(); + // Notify that tools are ready + globalController.notifyToolsReady(); } }).catch((error: unknown) => { console.error('[Extension] Failed to connect Tools MCP client:', error); - // Non-fatal - extension continues with ToolVault fallback + vscode.window.showErrorMessage(`Failed to connect to Tools MCP server: ${error instanceof Error ? error.message : String(error)}`); }); } else { - console.warn('[Extension] No Tools MCP server URL configured - using ToolVault fallback'); + console.warn('[Extension] No Tools MCP server URL configured'); + vscode.window.showWarningMessage('Tools MCP server URL is not configured. Tools will not be available. Configure debrief.toolsMcp.serverUrl in settings.'); } // Initialize Editor Activation Handler @@ -327,17 +208,6 @@ export function activate(context: vscode.ExtensionContext) { export async function deactivate() { console.warn('Debrief Extension is now deactivating...'); - // Stop Tool Vault server first (more critical to clean up) - if (toolVaultServer) { - try { - await toolVaultServer.stopServer(); - console.log('✅ Tool Vault server stopped'); - } catch (error) { - console.error('Error stopping Tool Vault server:', error); - } - toolVaultServer = null; - } - // Stop MCP server and wait for it to fully shut down if (mcpServer) { try { @@ -379,8 +249,5 @@ export async function deactivate() { // Clear editor ID manager EditorIdManager.clear(); - // Clear tool vault server reference - toolVaultServer = null; - console.warn('Debrief Extension deactivation complete'); } \ No newline at end of file diff --git a/apps/vs-code/src/providers/panels/debriefActivityProvider.ts b/apps/vs-code/src/providers/panels/debriefActivityProvider.ts index b3178653..222979b0 100644 --- a/apps/vs-code/src/providers/panels/debriefActivityProvider.ts +++ b/apps/vs-code/src/providers/panels/debriefActivityProvider.ts @@ -209,15 +209,15 @@ export class DebriefActivityProvider implements vscode.WebviewViewProvider { }); this._disposables.push(activeEditorSubscription); - // Subscribe to Tool Vault server ready events - const toolVaultReadySubscription = this._globalController.on('toolVaultReady', async () => { - console.warn('[DebriefActivityProvider] Tool Vault server ready - resetting failure flag and refreshing activity panel'); + // Subscribe to tools ready events + const toolsReadySubscription = this._globalController.on('toolsReady', async () => { + console.warn('[DebriefActivityProvider] Tools ready - resetting failure flag and refreshing activity panel'); // Reset failure flag and cache to allow fresh fetch this._toolListFailed = false; this._cachedToolList = null; await this._updateView(); }); - this._disposables.push(toolVaultReadySubscription); + this._disposables.push(toolsReadySubscription); } /** @@ -287,16 +287,16 @@ export class DebriefActivityProvider implements vscode.WebviewViewProvider { ? selectionState.selectedIds.map(id => String(id)) : []; - // Get tool list (stop trying after first failure until Tool Vault becomes ready) + // Get tool list (stop trying after first failure until tools become ready) let toolList: unknown = null; if (this._toolListFailed) { - // Tool Vault server failed to load - use cached value (null) until toolVaultReady event + // Tools MCP server failed to load - use cached value (null) until toolsReady event toolList = this._cachedToolList; } else if (this._cachedToolList !== null) { // We have a successful cached value toolList = this._cachedToolList; } else { - // First attempt or retry after toolVaultReady + // First attempt or retry after toolsReady try { toolList = await this._globalController.getToolIndex(); if (!toolList || typeof toolList !== 'object') { diff --git a/apps/vs-code/src/services/toolVaultConfig.ts b/apps/vs-code/src/services/toolVaultConfig.ts deleted file mode 100644 index fde93965..00000000 --- a/apps/vs-code/src/services/toolVaultConfig.ts +++ /dev/null @@ -1,151 +0,0 @@ -import * as vscode from 'vscode'; -import * as path from 'path'; -import * as fs from 'fs'; - -export interface ToolVaultConfig { - serverPath: string | null; - autoStart: boolean; - port: number; - host: string; -} - -export class ToolVaultConfigService { - private static instance: ToolVaultConfigService; - - private constructor() {} - - static getInstance(): ToolVaultConfigService { - if (!ToolVaultConfigService.instance) { - ToolVaultConfigService.instance = new ToolVaultConfigService(); - } - return ToolVaultConfigService.instance; - } - - /** - * Load Tool Vault configuration with priority order: - * 1. Environment Variable: DEBRIEF_TOOL_VAULT_PATH - * 2. Workspace Settings: .vscode/settings.json in project root - * 3. User Settings: Global VS Code settings - * 4. Default Fallback: Relative path detection for development - */ - getConfiguration(): ToolVaultConfig { - const config = vscode.workspace.getConfiguration('debrief.toolVault'); - - // Priority 1: Environment variable - const envPath = process.env.DEBRIEF_TOOL_VAULT_PATH || null; - - // Priority 2 & 3: VS Code configuration (workspace takes precedence over user) - const configServerPath = config.get('serverPath') || null; - const autoStart = config.get('autoStart', true); - const port = config.get('port', 60124); - const host = config.get('host', '127.0.0.1'); - - let serverPath: string | null = envPath || configServerPath; - - // Priority 4: Default fallback - look for relative paths in development - if (!serverPath) { - serverPath = this.detectDefaultServerPath(); - } - - const result: ToolVaultConfig = { - serverPath, - autoStart, - port, - host - }; - - // Validate the configuration - this.validateConfiguration(result); - - return result; - } - - /** - * Detect default Tool Vault server path for development scenarios - */ - private detectDefaultServerPath(): string | null { - // Common development paths relative to VS Code extension - const workspaceFolders = vscode.workspace.workspaceFolders; - - const candidatePaths: string[] = []; - - // Priority 1: Bundled .pyz in extension directory (for production/packaged extension) - const extensionPath = vscode.extensions.getExtension('ian.vs-code')?.extensionPath; - if (extensionPath) { - candidatePaths.push(path.join(extensionPath, 'tool-vault', 'toolvault.pyz')); - } - - // Priority 2-4: Development paths in workspace - if (workspaceFolders && workspaceFolders.length > 0) { - const workspaceRoot = workspaceFolders[0].uri.fsPath; - candidatePaths.push( - // In monorepo: libs/tool-vault-packager/dist/toolvault.pyz - path.join(workspaceRoot, 'libs', 'tool-vault-packager', 'dist', 'toolvault.pyz'), - // Alternative: tool-vault-packager/dist/toolvault.pyz - path.join(workspaceRoot, 'tool-vault-packager', 'dist', 'toolvault.pyz'), - // Direct in workspace: toolvault.pyz - path.join(workspaceRoot, 'toolvault.pyz') - ); - } - - for (const candidatePath of candidatePaths) { - if (fs.existsSync(candidatePath)) { - return candidatePath; - } - } - return null; - } - - /** - * Validate the loaded configuration and throw descriptive errors - */ - private validateConfiguration(config: ToolVaultConfig): void { - if (!config.serverPath) { - throw new Error( - 'Tool Vault server path not configured. Please set one of:\n' + - '1. Environment variable: DEBRIEF_TOOL_VAULT_PATH\n' + - '2. VS Code setting: debrief.toolVault.serverPath\n' + - '3. Place toolvault.pyz in workspace root or libs/tool-vault-packager/dist/' - ); - } - - if (!fs.existsSync(config.serverPath)) { - throw new Error(`Tool Vault server file not found: ${config.serverPath}`); - } - - if (!config.serverPath.endsWith('.pyz')) { - throw new Error(`Tool Vault server path must be a .pyz file: ${config.serverPath}`); - } - - if (config.port < 1024 || config.port > 65535) { - throw new Error(`Invalid port number: ${config.port}. Must be between 1024 and 65535.`); - } - - if (!config.host || config.host.trim() === '') { - throw new Error('Host address cannot be empty'); - } - } - - /** - * Get a human-readable description of the current configuration source - */ - getConfigurationSource(): string { - const envPath = process.env.DEBRIEF_TOOL_VAULT_PATH; - if (envPath) { - return `Environment variable (DEBRIEF_TOOL_VAULT_PATH)`; - } - - const config = vscode.workspace.getConfiguration('debrief.toolVault'); - const configServerPath = config.get('serverPath'); - if (configServerPath) { - const inspector = config.inspect('serverPath'); - if (inspector?.workspaceValue !== undefined) { - return 'Workspace settings (.vscode/settings.json)'; - } else if (inspector?.globalValue !== undefined) { - return 'User settings (global VS Code settings)'; - } - } - - return 'Default fallback (auto-detected)'; - } -} \ No newline at end of file diff --git a/apps/vs-code/src/services/toolVaultServer.ts b/apps/vs-code/src/services/toolVaultServer.ts deleted file mode 100644 index 54144e98..00000000 --- a/apps/vs-code/src/services/toolVaultServer.ts +++ /dev/null @@ -1,626 +0,0 @@ -import * as vscode from 'vscode'; -import { spawn, ChildProcess } from 'child_process'; -import { ToolVaultConfig, ToolVaultConfigService } from './toolVaultConfig'; - -export interface ToolInfo { - name: string; - description: string; - parameters: Array<{ - name: string; - type: string; - description: string; - required: boolean; - }>; -} - -export interface ToolVaultCommand { - command: string; - parameters?: Record; -} - -export interface ToolVaultResponse { - result?: unknown; - error?: { - message: string; - code: number | string; - }; -} - -export class ToolVaultServerService { - private static instance: ToolVaultServerService; - private process: ChildProcess | null = null; - private config: ToolVaultConfig | null = null; - private outputChannel: vscode.OutputChannel; - private configService: ToolVaultConfigService; - private isStarting = false; - private startPromise: Promise | null = null; - private onServerReadyCallback?: () => void; - - private constructor() { - this.outputChannel = vscode.window.createOutputChannel('Debrief Tools'); - this.configService = ToolVaultConfigService.getInstance(); - } - - static getInstance(): ToolVaultServerService { - if (!ToolVaultServerService.instance) { - ToolVaultServerService.instance = new ToolVaultServerService(); - } - return ToolVaultServerService.instance; - } - - /** - * Set callback to be called when server becomes ready - */ - setOnServerReadyCallback(callback: () => void): void { - this.onServerReadyCallback = callback; - } - - /** - * Start the Tool Vault server if not already running - */ - async startServer(): Promise { - if (this.isStarting && this.startPromise) { - return this.startPromise; - } - - if (this.isRunning()) { - this.log('Tool Vault server is already running'); - return; - } - - this.isStarting = true; - this.startPromise = this._startServer(); - - try { - await this.startPromise; - } finally { - this.isStarting = false; - this.startPromise = null; - } - } - - private async _startServer(): Promise { - let pythonInterpreter = 'python'; // Default fallback - - try { - this.config = this.configService.getConfiguration(); - this.log(`Loading configuration from: ${this.configService.getConfigurationSource()}`); - this.log(`Server path: ${this.config.serverPath}`); - this.log(`Host: ${this.config.host}:${this.config.port}`); - - // First, check if there's already a compatible server running - const existingServerCheck = await this.healthCheck(); - if (existingServerCheck) { - this.log('Connected to existing Tool Vault server on ' + this.config.host + ':' + this.config.port); - // Notify that server is ready - this.log('Triggering server ready callback for existing server'); - if (this.onServerReadyCallback) { - this.onServerReadyCallback(); - } - return; // Success - no need to start new process - } - - pythonInterpreter = await this.detectPythonInterpreter(); - this.log(`Using Python interpreter: ${pythonInterpreter}`); - - // Check if port is available (we already checked for Tool Vault server above) - const isPortAvailable = await this.checkPortAvailable(this.config.port); - if (!isPortAvailable) { - throw new Error( - `Port ${this.config.port} is already in use by another service. Please configure a different port ` + - `in VS Code settings (debrief.toolVault.port) or stop the process using this port.` - ); - } - - // Start the server process - const args = [ - this.config.serverPath!, - 'serve', - '--host', this.config.host, - '--port', this.config.port.toString() - ]; - - this.log(`Starting Tool Vault server: ${pythonInterpreter} ${args.join(' ')}`); - - this.process = spawn(pythonInterpreter, args, { - stdio: ['ignore', 'pipe', 'pipe'], - detached: false - }); - - // Handle process output - if (this.process.stdout) { - this.process.stdout.on('data', (data) => { - this.log(`[stdout] ${data.toString().trim()}`); - }); - } - - if (this.process.stderr) { - this.process.stderr.on('data', (data) => { - const stderr = data.toString().trim(); - this.log(`[stderr] ${stderr}`); - // Show critical startup errors immediately - if (stderr.includes('Error') || stderr.includes('Exception') || stderr.includes('Failed')) { - console.error(`[ToolVault Critical] ${stderr}`); - } - }); - } - - // Handle process events - this.process.on('error', (error) => { - this.log(`Process error: ${error.message}`); - this.process = null; - this.config = null; // Clear config when process fails - }); - - this.process.on('exit', (code, signal) => { - this.log(`Process exited with code ${code}, signal ${signal}`); - this.process = null; - // Only clear config on non-zero exit codes (failures) - // Some servers exit gracefully after starting (daemon pattern) - if (code !== 0) { - this.config = null; - } - }); - - // Wait for server to be ready - await this.waitForServerReady(); - this.log('Tool Vault server started successfully'); - - // Notify that server is ready - this.log('Triggering server ready callback for new server'); - if (this.onServerReadyCallback) { - this.onServerReadyCallback(); - } - - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - this.log(`Failed to start Tool Vault server: ${errorMessage}`); - - // Provide enhanced diagnostics - const diagnostics = [ - `Error: ${errorMessage}`, - `Server Path: ${this.config?.serverPath}`, - `Python Interpreter: ${pythonInterpreter}`, - `Host: ${this.config?.host}:${this.config?.port}`, - `Process Status: ${this.process ? 'Created' : 'Not Created'}`, - `Port Available: ${await this.checkPortAvailable(this.config?.port || 60124) ? 'Yes' : 'No'}` - ]; - - console.error(`[ToolVault Diagnostics]\n${diagnostics.join('\n')}`); - this.log(`Diagnostics:\n${diagnostics.join('\n')}`); - - // Clear config on startup failure - this.config = null; - await this.stopServer(); - throw new Error(`Tool Vault server startup failed.\n${diagnostics.join('\n')}`); - } - } - - /** - * Stop the Tool Vault server gracefully - */ - async stopServer(): Promise { - const configBackup = this.config; // Save config before clearing - - if (!this.process) { - this.log('Tool Vault server process not found - attempting HTTP shutdown'); - // Try to stop server via HTTP if we have config - if (configBackup?.host && configBackup?.port) { - try { - await this.shutdownViaHttp(configBackup); - this.log('Tool Vault server stopped via HTTP'); - } catch { - this.log('HTTP shutdown failed - server may have already stopped'); - } - } - // Clear config after attempting shutdown - this.config = null; - return; - } - - this.log('Stopping Tool Vault server...'); - - // Clear config immediately when explicitly stopping - this.config = null; - - return new Promise((resolve) => { - const process = this.process!; - let resolved = false; - - const cleanup = () => { - if (!resolved) { - resolved = true; - this.process = null; - this.log('Tool Vault server stopped'); - resolve(); - } - }; - - // Set up timeout for forceful kill - const killTimeout = setTimeout(() => { - if (process && !process.killed) { - this.log('Force killing Tool Vault server (SIGKILL)'); - process.kill('SIGKILL'); - } - cleanup(); - }, 5000); - - // Handle process exit - process.on('exit', () => { - clearTimeout(killTimeout); - cleanup(); - }); - - // Try graceful shutdown first - if (!process.killed) { - this.log('Sending SIGTERM to Tool Vault server'); - process.kill('SIGTERM'); - } else { - cleanup(); - } - }); - } - - /** - * Check if the server is currently running - */ - isRunning(): boolean { - // Server is considered running if we have a successful startup (config loaded) - // and either the process is still running OR a health check would pass - // The process might exit after successful startup (daemon pattern) - const hasConfig = this.config !== null; - - // Debug logging for troubleshooting - if (!hasConfig) { - this.log('Server not running - no configuration found'); - } - - return hasConfig; - } - - /** - * Perform health check on the running server - */ - async healthCheck(): Promise { - if (!this.isRunning() || !this.config) { - return false; - } - - try { - const response = await fetch(`http://${this.config.host}:${this.config.port}/health`); - return response.ok; - } catch (error) { - this.log(`Health check failed: ${error instanceof Error ? error.message : String(error)}`); - return false; - } - } - - /** - * Get the list of available tools from the server - */ - async getToolIndex(): Promise { - if (!this.isRunning() || !this.config) { - throw new Error('Tool Vault server is not running'); - } - - try { - const url = `http://${this.config.host}:${this.config.port}/tools/list`; - this.log(`Fetching tool index from: ${url}`); - - const response = await fetch(url); - if (!response.ok) { - throw new Error(`HTTP ${response.status}: ${response.statusText}`); - } - - const data = await response.json(); - this.log(`Tool index retrieved successfully: ${JSON.stringify(data).slice(0, 100)}...`); - return data; - } catch (error) { - const message = `Failed to get tool index: ${error instanceof Error ? error.message : String(error)}`; - this.log(`Tool index fetch error: ${message}`); - throw new Error(message); - } - } - - /** - * Execute a command on the Tool Vault server - */ - async executeCommand(command: ToolVaultCommand): Promise { - if (!this.isRunning() || !this.config) { - throw new Error('Tool Vault server is not running'); - } - - try { - // Transform to the format expected by tool-vault server - // Convert object parameters to array of {name, value} objects - const argumentsArray = Object.entries(command.parameters || {}).map(([name, value]) => ({ - name, - value - })); - - const payload = { - name: command.command, - arguments: argumentsArray - }; - - this.log(`Executing tool: ${payload.name}`); - - const response = await fetch(`http://${this.config.host}:${this.config.port}/tools/call`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(payload), - }); - - const data = await response.json(); - - // For non-200 responses, log the error details - if (!response.ok) { - console.warn('[ToolVaultServer] HTTP Error Response:', { - status: response.status, - statusText: response.statusText, - errorMessage: data.error, - errorDetail: data.detail, - dataKeys: Object.keys(data || {}) - }); - - // Build comprehensive error message with all available details - const errorParts = []; - - if (data.error) { - errorParts.push(`Error: ${data.error}`); - } - - if (data.detail) { - errorParts.push(`Details: ${typeof data.detail === 'string' ? data.detail : JSON.stringify(data.detail, null, 2)}`); - } - - if (data.message && data.message !== data.error) { - errorParts.push(`Message: ${data.message}`); - } - - // If we have no specific error details, use the HTTP status - const fullErrorMessage = errorParts.length > 0 - ? errorParts.join('\n\n') - : `HTTP ${response.status}: ${response.statusText}`; - - this.log(`Tool execution failed: ${fullErrorMessage}`); - - return { - error: { - message: fullErrorMessage, - code: response.status - } - }; - } - - return { result: data }; - } catch (error) { - const message = `Failed to execute command: ${error instanceof Error ? error.message : String(error)}`; - console.warn('[ToolVaultServer] executeCommand caught error:', error); - this.log(message); - return { - error: { - message, - code: 500 - } - }; - } - } - - /** - * Detect available Python interpreter - */ - private async detectPythonInterpreter(): Promise { - // Priority 1: VS Code Python extension interpreter - try { - const pythonExtension = vscode.extensions.getExtension('ms-python.python'); - if (pythonExtension && pythonExtension.isActive) { - const pythonPath = pythonExtension.exports?.settings?.getExecutionDetails?.()?.execCommand?.[0]; - if (pythonPath && await this.checkPythonExecutable(pythonPath)) { - return pythonPath; - } - } - } catch (error) { - this.log(`Could not get Python path from extension: ${error}`); - } - - // Priority 2: System Python - const systemPythons = ['python3', 'python']; - for (const pythonCmd of systemPythons) { - if (await this.checkPythonExecutable(pythonCmd)) { - return pythonCmd; - } - } - - throw new Error( - 'No suitable Python interpreter found. Please install Python or configure the VS Code Python extension.' - ); - } - - /** - * Check if a Python executable is available and suitable - */ - private async checkPythonExecutable(pythonPath: string): Promise { - return new Promise((resolve) => { - const proc = spawn(pythonPath, ['--version'], { stdio: 'pipe' }); - proc.on('close', (code) => resolve(code === 0)); - proc.on('error', () => resolve(false)); - }); - } - - /** - * Check if a port is available - */ - private async checkPortAvailable(port: number): Promise { - return new Promise((resolve) => { - // eslint-disable-next-line @typescript-eslint/no-require-imports - const net = require('net'); - const server = net.createServer(); - - server.listen(port, () => { - server.once('close', () => resolve(true)); - server.close(); - }); - - server.on('error', () => resolve(false)); - }); - } - - /** - * Wait for the server to be ready by polling the health endpoint - */ - private async waitForServerReady(): Promise { - const maxAttempts = 15; - const delays = [100, 200, 300, 500, 500, 750, 750, 1000, 1000, 1000, 1500, 1500, 2000, 2000, 2000]; // More responsive early checks - const totalTime = delays.reduce((a, b) => a + b, 0); - - this.log(`Waiting for server to be ready (max ${totalTime/1000}s)...`); - - for (let i = 0; i < maxAttempts; i++) { - await new Promise(resolve => setTimeout(resolve, delays[i])); - - this.log(`Health check attempt ${i + 1}/${maxAttempts}...`); - if (await this.healthCheck()) { - this.log('Server health check passed!'); - return; - } - } - - // Provide additional diagnostics for timeout - const processStatus = this.process ? (this.process.killed ? 'Killed' : 'Running') : 'Not Created'; - const diagnostics = [ - `Timeout: Server failed to start within ${totalTime/1000} seconds`, - `Process Status: ${processStatus}`, - `Process PID: ${this.process?.pid || 'N/A'}`, - `Health Endpoint: http://${this.config?.host}:${this.config?.port}/health`, - 'Check the "Debrief Tools" output channel for server logs' - ]; - - throw new Error(`Tool Vault server startup timeout.\n${diagnostics.join('\n')}`); - } - - /** - * Log message to the output channel - */ - private log(message: string): void { - const timestamp = new Date().toISOString(); - this.outputChannel.appendLine(`[${timestamp}] ${message}`); - } - - /** - * Get the output channel for debugging - */ - getOutputChannel(): vscode.OutputChannel { - return this.outputChannel; - } - - /** - * Get current configuration - */ - getConfig(): ToolVaultConfig | null { - return this.config; - } - - /** - * Attempt to shutdown server via HTTP request - */ - private async shutdownViaHttp(config: ToolVaultConfig): Promise { - try { - // Create an AbortController for timeout handling - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 5000); - - try { - const response = await fetch(`http://${config.host}:${config.port}/shutdown`, { - method: 'POST', - signal: controller.signal - }); - - clearTimeout(timeoutId); - - if (response.ok) { - this.log('Server acknowledged shutdown request'); - } else { - throw new Error(`HTTP ${response.status}`); - } - } catch (fetchError) { - clearTimeout(timeoutId); - throw fetchError; - } - } catch (error) { - // If no shutdown endpoint exists, the server will keep running - // This is expected for many server implementations - throw new Error(`HTTP shutdown not supported: ${error}`); - } - } - - /** - * Restart the server (stop and start) - */ - async restartServer(): Promise { - this.log('Restarting Tool Vault server...'); - await this.stopServer(); - await this.startServer(); - } - - - /** - * Execute a tool command with proper error handling and logging - */ - async executeToolCommand(toolName: string, parameters: Record): Promise<{ success: boolean; result?: unknown; error?: string }> { - try { - const isRunning = this.isRunning(); - const hasConfig = this.config !== null; - const hasProcess = this.process !== null; - - this.log(`Tool execution check - isRunning: ${isRunning}, hasConfig: ${hasConfig}, hasProcess: ${hasProcess}`); - - if (!isRunning) { - return { - success: false, - error: 'Tool Vault server is not running' - }; - } - - // Log command execution attempt - this.log(`Executing tool: ${toolName}`); - this.log(`Parameters: ${JSON.stringify(parameters, null, 2)}`); - - // Execute command on server - const response = await this.executeCommand({ - command: toolName, - parameters - }); - - if (response.error) { - const errorMessage = `Tool execution failed: ${response.error.message}`; - this.log(`Error: ${errorMessage}`); - return { - success: false, - error: errorMessage - }; - } - - // Log success - const successMessage = `Tool "${toolName}" executed successfully`; - this.log(`Success: ${successMessage}`); - this.log(`Result: ${JSON.stringify(response.result, null, 2)}`); - - return { - success: true, - result: response.result - }; - - } catch (error) { - const errorMessage = `Failed to execute tool "${toolName}": ${error instanceof Error ? error.message : String(error)}`; - this.log(`Error: ${errorMessage}`); - return { - success: false, - error: errorMessage - }; - } - } - -} \ No newline at end of file