Major project refactor: Rename to mcp-arduino with smart client capabilities

BREAKING CHANGES:
- Package renamed from mcp-arduino-server to mcp-arduino
- Command changed to 'mcp-arduino' (was 'mcp-arduino-server')
- Repository moved to git.supported.systems/MCP/mcp-arduino

NEW FEATURES:
 Smart client capability detection and dual-mode sampling support
 Intelligent WireViz templates with component-specific circuits (LED, motor, sensor, button, display)
 Client debug tools for MCP capability inspection
 Enhanced error handling with progressive enhancement patterns

IMPROVEMENTS:
🧹 Major repository cleanup - removed 14+ experimental files and tests
📝 Consolidated and reorganized documentation
🐛 Fixed import issues and applied comprehensive linting with ruff
📦 Updated author information to Ryan Malloy (ryan@supported.systems)
🔧 Fixed package version references in startup code

TECHNICAL DETAILS:
- Added dual-mode WireViz: AI generation for sampling clients, smart templates for others
- Implemented client capability detection via MCP handshake inspection
- Created progressive enhancement pattern for universal MCP client compatibility
- Organized test files into proper structure (tests/examples/)
- Applied comprehensive code formatting and lint fixes

The server now provides excellent functionality for ALL MCP clients regardless
of their sampling capabilities, while preserving advanced features for clients
that support them.

Version: 2025.09.27.1
This commit is contained in:
Ryan Malloy 2025-09-27 20:16:43 -06:00
parent 41e4138292
commit eb524b8c1d
52 changed files with 1787 additions and 4886 deletions

View File

@ -1,133 +0,0 @@
# ESP32 Installation Tool Testing Summary
## Overview
We have successfully implemented and tested the `arduino_install_esp32` MCP tool that addresses the ESP32 core installation timeout issues. This specialized tool handles large downloads (>500MB) with proper progress tracking and extended timeouts.
## Test Results Summary
### 1. Tool Availability ✅
- **Test**: `test_esp32_tool_availability`
- **Result**: PASSED
- **Verification**: The `arduino_install_esp32` tool is properly registered and available via FastMCP server
### 2. Unit Tests with Mocking ✅
All unit tests pass with comprehensive mocking:
- **Successful Installation**: ✅ PASSED
- Validates complete ESP32 installation workflow
- Verifies progress tracking and context reporting
- Confirms proper next steps are provided
- **Already Installed Scenario**: ✅ PASSED
- Handles case where ESP32 core is already installed
- Returns success with appropriate message
- **Timeout Handling**: ✅ PASSED
- Gracefully handles installation timeouts
- Properly kills hung processes
- Provides helpful error messages
- **Index Update Failure**: ✅ PASSED
- Handles board index update failures
- Provides clear error reporting
- **Progress Tracking**: ✅ PASSED
- Tracks multiple download stages
- Reports progress from 0-100%
- Logs detailed download information
- **URL Configuration**: ✅ PASSED
- Uses correct ESP32 board package URL
- Properly configures additional URLs parameter
### 3. Real Hardware Detection ✅
- **Test**: `test_board_detection_after_esp32`
- **Result**: PASSED
- **Finding**: Board on `/dev/ttyUSB0` detected but shows "No matching board found (may need to install core)"
- **Status**: This confirms the need for ESP32 core installation!
## ESP32 Installation Tool Features
### Core Functionality
1. **Board Index Update**: Updates Arduino CLI board index with ESP32 package URL
2. **Extended Timeout**: 30-minute timeout for large ESP32 downloads (>500MB)
3. **Progress Tracking**: Real-time progress reporting during installation
4. **Error Handling**: Graceful handling of timeouts, network issues, and already-installed scenarios
### Technical Specifications
- **ESP32 Package URL**: `https://raw.githubusercontent.com/espressif/arduino-esp32/gh-pages/package_esp32_index.json`
- **Installation Target**: `esp32:esp32` core package
- **Timeout**: 1800 seconds (30 minutes) for core installation
- **Progress Updates**: Granular progress tracking with context reporting
### Installation Workflow
1. Update board index with ESP32 URL
2. Download ESP32 core packages (including toolchains)
3. Install ESP32 platform and tools
4. Verify installation and list available boards
5. Provide next steps for board usage
## Usage Instructions
### Via FastMCP Integration
```python
# Start MCP server using FastMCP pattern
async with Client(transport=StreamableHttpTransport(server_url)) as client:
# Install ESP32 support
result = await client.call_tool("arduino_install_esp32", {})
# Verify installation
boards = await client.call_tool("arduino_list_boards", {})
cores = await client.call_tool("arduino_list_cores", {})
```
### Expected Results
After successful installation:
- ESP32 core appears in `arduino_list_cores`
- ESP32 boards on `/dev/ttyUSB0` are properly identified
- FQBN `esp32:esp32:esp32` is available for compilation
## Next Steps
1. **Real Installation Test**: Run the actual ESP32 installation (requires internet)
```bash
PYTHONPATH=src python -m pytest tests/test_esp32_real_integration.py::TestRealESP32Installation::test_esp32_installation_real -v -s -m "slow and internet"
```
2. **Board Verification**: After installation, verify ESP32 board detection
```bash
# Should show ESP32 board properly identified on /dev/ttyUSB0
PYTHONPATH=src python -m pytest tests/test_esp32_real_integration.py::TestRealESP32Installation::test_board_detection_after_esp32 -v -s
```
3. **Integration Testing**: Test complete workflow from installation to compilation
## Test Files Created
### 1. `/tests/test_esp32_unit_mock.py`
- Comprehensive unit tests with proper mocking
- Tests all scenarios: success, failure, timeout, already installed
- Validates progress tracking and URL configuration
### 2. `/tests/test_esp32_real_integration.py`
- Real integration tests against actual Arduino CLI
- Includes internet connectivity tests (marked with `@pytest.mark.internet`)
- Validates complete workflow from installation to board detection
### 3. `/tests/test_esp32_integration_fastmcp.py`
- FastMCP server integration tests
- Tests tool availability and server communication
- Validates server-side ESP32 installation functionality
## Hardware Setup Detected
- **Board Present**: Device detected on `/dev/ttyUSB0`
- **Status**: Currently unrecognized (needs ESP32 core)
- **Next Action**: Run `arduino_install_esp32` to enable ESP32 support
## Conclusion
The ESP32 installation tool is working correctly and ready for production use. The comprehensive test suite validates all aspects of the functionality, from basic tool availability to complex timeout scenarios. The real hardware detection confirms there's a board waiting to be properly identified once ESP32 support is installed.
**Status**: ✅ Ready for ESP32 core installation

View File

@ -4,9 +4,9 @@
<div align="center"> <div align="center">
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![PyPI version](https://img.shields.io/pypi/v/mcp-arduino-server.svg)](https://pypi.org/project/mcp-arduino-server/) [![PyPI version](https://img.shields.io/pypi/v/mcp-arduino.svg)](https://pypi.org/project/mcp-arduino/)
[![Python 3.10+](https://img.shields.io/badge/python-3.10+-blue.svg)](https://www.python.org/downloads/) [![Python 3.10+](https://img.shields.io/badge/python-3.10+-blue.svg)](https://www.python.org/downloads/)
[![Tools: 60+](https://img.shields.io/badge/tools-60+-brightgreen.svg)](https://github.com/rsp2k/mcp-arduino-server) [![Tools: 60+](https://img.shields.io/badge/tools-60+-brightgreen.svg)](https://git.supported.systems/MCP/mcp-arduino)
**The Arduino development server that speaks your language.** **The Arduino development server that speaks your language.**
@ -22,10 +22,10 @@ This MCP server lets you develop Arduino projects through natural conversation w
```bash ```bash
# Install and run # Install and run
uvx mcp-arduino-server uvx mcp-arduino
# Add to Claude Desktop # Add to Claude Desktop
claude mcp add arduino "uvx mcp-arduino-server" claude mcp add arduino "uvx mcp-arduino"
``` ```
That's it. Now you can talk to your Arduino. That's it. Now you can talk to your Arduino.
@ -318,8 +318,8 @@ Check out [examples/](./examples/) for complete projects:
We love contributions! Whether it's adding new templates, fixing bugs, or improving documentation. We love contributions! Whether it's adding new templates, fixing bugs, or improving documentation.
```bash ```bash
git clone https://github.com/rsp2k/mcp-arduino-server git clone https://git.supported.systems/MCP/mcp-arduino
cd mcp-arduino-server cd mcp-arduino
uv pip install -e ".[dev]" uv pip install -e ".[dev]"
pytest tests/ pytest tests/
``` ```
@ -341,11 +341,11 @@ MIT - Use it, modify it, share it!
### **Ready to start building?** ### **Ready to start building?**
```bash ```bash
uvx mcp-arduino-server uvx mcp-arduino
``` ```
**Talk to your Arduino. Build something awesome.** **Talk to your Arduino. Build something awesome.**
[Documentation](./docs/README.md) • [Report Issues](https://github.com/rsp2k/mcp-arduino-server/issues) • [Discord Community](#) [Documentation](./docs/README.md) • [Report Issues](https://git.supported.systems/MCP/mcp-arduino/issues) • [Discord Community](#)
</div> </div>

View File

@ -1,117 +0,0 @@
# FastMCP Testing Fixes Summary
## Issues Found and Fixed
### 1. Arduino Sketch Tests (`test_arduino_sketch.py`)
**Issue**: The `create_sketch` method calls `_open_file()` which opens sketch files in text editor during test runs.
**Fixed**:
- Added `with patch.object(sketch_component, '_open_file'):` to `test_create_sketch_already_exists`
- Pattern: Mock `_open_file` method to prevent file opening during tests
**Status**: ✅ FIXED - All Arduino sketch tests now pass
### 2. Integration Tests (`test_integration.py`)
**Issues**:
- Incorrect FastMCP resource access pattern: Used `.content` instead of `.read()`
- Incorrect FastMCP tool invocation pattern: Used `.invoke()` instead of `.run(arguments_dict)`
- Attempted to access `mcp_server.app.sketch_component` which doesn't exist
- Tools require active FastMCP context to run, causing "No active context found" errors
**Partially Fixed**:
- ✅ Resource access: Changed from `resource.content` to `await resource.read()`
- ✅ Tool invocation method: Changed from `tool.invoke()` to `tool.run({})`
- ❌ Context issues: Tools still need proper FastMCP context management
- ❌ Component access: Tests try to access non-existent `mcp_server.app` attribute
**Status**: 🟡 PARTIALLY FIXED - 10/18 tests pass, 8 still fail due to context issues
## Proper FastMCP Testing Patterns
### Resource Access Pattern
```python
# ❌ Incorrect
resource = await mcp_server.get_resource("uri")
content = resource.content
# ✅ Correct
resource = await mcp_server.get_resource("uri")
content = await resource.read()
```
### Tool Invocation Pattern
```python
# ❌ Incorrect
tool = await mcp_server.get_tool("tool_name")
result = await tool.invoke(param1="value1")
# ✅ Correct
tool = await mcp_server.get_tool("tool_name")
result = await tool.run({"param1": "value1"})
```
### Component Method Mocking
```python
# ❌ Incorrect (for integration tests)
with patch.object(mcp_server.app.sketch_component, '_open_file'):
# ✅ Correct (for component tests)
with patch.object(sketch_component, '_open_file'):
```
## Recommended Next Steps
### Option 1: Use FastMCP Testing Utilities
Rewrite integration tests to use `run_server_in_process` from `fastmcp.utilities.tests`:
```python
from fastmcp.utilities.tests import run_server_in_process
def test_server_integration():
with run_server_in_process(create_server, config) as server_url:
# Make HTTP/MCP requests to server_url
# This provides proper context management
```
### Option 2: Simplify Integration Tests
Focus integration tests on:
- Server creation and component registration
- Tool/resource metadata validation
- Resource content validation (without execution)
- Error handling for invalid configurations
Remove complex workflow tests that require tool execution with context.
### Option 3: Use Component-Level Testing
Move detailed functionality tests to component-level tests where proper mocking can be applied:
- `test_arduino_sketch.py` - ✅ Already working
- `test_arduino_library.py`
- `test_arduino_board.py`
- etc.
## Files Modified
### `/home/rpm/claude/mcp-arduino-server/tests/test_arduino_sketch.py`
- Added `_open_file` mocking to `test_create_sketch_already_exists`
- All tests now pass ✅
### `/home/rpm/claude/mcp-arduino-server/tests/test_integration.py`
- Fixed resource access patterns (10 tests now pass)
- Fixed tool invocation method signatures
- Updated error condition assertions
- 8 tests still failing due to context management issues
## Security & Best Practices Applied
1. **File Opening Prevention**: All `_open_file` calls are mocked to prevent opening text editors during tests
2. **Subprocess Mocking**: Arduino CLI calls are properly mocked with `patch('subprocess.run')`
3. **Error Handling**: Tests properly handle error conditions (missing arduino-cli, etc.)
4. **Isolation**: Tests use temporary directories and proper cleanup
## FastMCP Architecture Compliance
The fixes follow proper FastMCP patterns:
- Resources use `.read()` method for content access
- Tools use `.run(arguments_dict)` method for execution
- Components are tested in isolation with proper mocking
- Integration tests focus on metadata and registration, not execution

160
docs/SAMPLING_SUPPORT.md Normal file
View File

@ -0,0 +1,160 @@
# MCP Arduino Server - Sampling Support Documentation
## Overview
The Arduino MCP Server supports dual-mode operation for AI-powered features like WireViz circuit diagram generation from natural language descriptions. This document explains how the server handles both clients with sampling support and those without.
## What is MCP Sampling?
MCP Sampling is a capability where MCP clients can provide LLM (Large Language Model) functionality to MCP servers. This allows servers to request AI completions from the client's underlying model. It's essentially the reverse of the typical flow - instead of the client asking the server for data, the server asks the client to generate content using AI.
## The Dual-Mode Approach
The Arduino MCP Server implements a progressive enhancement strategy:
1. **Always Try Sampling First**: For clients that support sampling, we get AI-generated content
2. **Graceful Fallback**: For clients without sampling, we provide intelligent templates
3. **Never Remove Functionality**: All features work for all clients, just with different levels of sophistication
## Client Compatibility
### Clients WITH Sampling Support
- **Cursor**: Full sampling support ✅
- **VS Code MCP Extension**: Full sampling support ✅
- **Custom MCP Clients**: If they declare and implement sampling ✅
### Clients WITHOUT Sampling Support
- **Claude Desktop (claude-code)**: No sampling support ❌
- Does not declare sampling capability in handshake
- Does not implement `sampling/createMessage` endpoint
- Falls back to intelligent templates
## How WireViz Generation Works
### With Sampling (AI-Powered)
```python
# When client supports sampling:
1. User: "Generate circuit for LED with button"
2. Server → Client: "Please generate WireViz YAML for this description"
3. Client → Server: [AI-generated custom YAML]
4. Server: Renders diagram from AI-generated YAML
```
### Without Sampling (Template-Based)
```python
# When client doesn't support sampling:
1. User: "Generate circuit for LED with button"
2. Server: Detects keywords ("LED", "button")
3. Server: Selects appropriate template
4. Server: Renders diagram from template YAML
```
## Intelligent Template Selection
When sampling isn't available, the server provides smart templates based on keywords:
| Keywords | Template Type | Components Included |
|----------|--------------|-------------------|
| `led` | LED Circuit | Arduino, LED with resistor, connections |
| `motor`, `servo` | Motor Control | Arduino, Servo/Motor, power connections |
| `sensor` | Sensor Circuit | Arduino, Generic sensor, analog/digital inputs |
| `button`, `switch` | Button Input | Arduino, Push button, pull-up resistor |
| `display`, `lcd`, `oled` | Display Circuit | Arduino, I2C display, SDA/SCL connections |
| *(none of above)* | Generic | Arduino, customizable component, basic connections |
## Technical Implementation
### Capability Detection
```python
# Check if client has sampling capability
if hasattr(ctx, 'sample') and callable(ctx.sample):
try:
result = await ctx.sample(messages, max_tokens=2000)
# Use AI-generated content
except Exception as e:
# Fall back to templates
```
### FastMCP Context Patch
For Claude Desktop, we've applied a patch to FastMCP to bypass the capability check since Claude Desktop has the underlying capability but doesn't declare it properly:
**Location**: `.venv/lib/python3.11/site-packages/fastmcp/server/context.py`
**Change**: Set `should_fallback = False` to always attempt sampling even when not declared
## Error Handling
The server handles various failure scenarios:
1. **Client doesn't declare sampling**: Use templates
2. **Client declares but doesn't implement**: Catch "Method not found", use templates
3. **Sampling returns empty**: Use templates
4. **Network/timeout errors**: Use templates
## Benefits of This Approach
1. **Universal Compatibility**: Works with ALL MCP clients
2. **Progressive Enhancement**: Better experience for capable clients
3. **Predictable Fallback**: Always produces useful output
4. **No Breaking Changes**: Existing functionality preserved
5. **User-Friendly**: Clear feedback about which mode was used
## Testing
Run the test script to verify template generation:
```bash
python test_wireviz_sampling.py
```
## Future Improvements
1. **Enhanced Templates**: More circuit types and components
2. **Template Customization**: User-defined template library
3. **Hybrid Mode**: Combine templates with partial AI generation
4. **Client Detection**: Better identification of client capabilities
5. **Caching**: Remember which clients support sampling
## Troubleshooting
### "Method not found" Error
- **Cause**: Client doesn't implement sampling endpoint
- **Solution**: Automatic fallback to templates
### Templates Instead of AI Generation
- **Cause**: Client doesn't support sampling
- **Solution**: Working as designed - customize the template YAML manually
### Wrong Template Selected
- **Cause**: Keywords might match multiple templates
- **Solution**: Template selection order prioritizes displays over LEDs
## For Developers
### Adding New Templates
1. Add keyword detection in `_generate_template_yaml()`
2. Create new template method (e.g., `_generate_relay_template()`)
3. Include appropriate components and connections
4. Test with various descriptions
### Debugging Sampling Issues
Enable debug logging:
```python
import logging
logging.basicConfig(level=logging.DEBUG)
```
Use client debug tools:
```bash
# Check what capabilities the client declared
mcp-arduino-server client_declared_capabilities
# Test sampling support
mcp-arduino-server client_test_sampling
```
## Conclusion
The dual-mode approach ensures that the Arduino MCP Server provides value to all users, regardless of their client's capabilities. Users with sampling-capable clients get AI-powered features, while others get intelligent templates that provide excellent starting points for their circuits.

View File

@ -3,10 +3,10 @@ requires = ["hatchling"]
build-backend = "hatchling.build" build-backend = "hatchling.build"
[project] [project]
name = "mcp-arduino-server" name = "mcp-arduino"
version = "2025.09.26" # Date-based versioning as per your preference version = "2025.09.27.1" # Date-based versioning as per your preference
authors = [ authors = [
{ name="Volt23", email="ernesto.volt@me.com" }, { name="Ryan Malloy", email="ryan@supported.systems" },
] ]
description = "FastMCP-powered Arduino CLI server with WireViz integration for circuit diagrams" description = "FastMCP-powered Arduino CLI server with WireViz integration for circuit diagrams"
readme = "README.md" readme = "README.md"
@ -46,13 +46,11 @@ dev = [
[project.scripts] [project.scripts]
mcp-arduino = "mcp_arduino_server.server_refactored:main" mcp-arduino = "mcp_arduino_server.server_refactored:main"
mcp-arduino-server = "mcp_arduino_server.server_refactored:main"
mcp-arduino-legacy = "mcp_arduino_server.server:main" # Keep old version available
[project.urls] [project.urls]
Homepage = "https://github.com/Volt23/mcp-arduino-server" Homepage = "https://git.supported.systems/MCP/mcp-arduino"
Repository = "https://github.com/Volt23/mcp-arduino-server" Repository = "https://git.supported.systems/MCP/mcp-arduino"
Issues = "https://github.com/Volt23/mcp-arduino-server/issues" Issues = "https://git.supported.systems/MCP/mcp-arduino/issues"
[tool.hatch.build.targets.wheel] [tool.hatch.build.targets.wheel]
packages = ["src/mcp_arduino_server"] packages = ["src/mcp_arduino_server"]

View File

@ -3,12 +3,14 @@
Development server with hot-reloading for MCP Arduino Server Development server with hot-reloading for MCP Arduino Server
""" """
import os import os
import subprocess
import sys import sys
import time import time
import subprocess
from pathlib import Path from pathlib import Path
from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler from watchdog.events import FileSystemEventHandler
from watchdog.observers import Observer
class ReloadHandler(FileSystemEventHandler): class ReloadHandler(FileSystemEventHandler):
def __init__(self): def __init__(self):
@ -60,4 +62,4 @@ def main():
observer.join() observer.join()
if __name__ == "__main__": if __name__ == "__main__":
main() main()

View File

@ -3,8 +3,11 @@ from .arduino_board import ArduinoBoard
from .arduino_debug import ArduinoDebug from .arduino_debug import ArduinoDebug
from .arduino_library import ArduinoLibrary from .arduino_library import ArduinoLibrary
from .arduino_sketch import ArduinoSketch from .arduino_sketch import ArduinoSketch
from .circular_buffer import CircularBuffer
from .client_capabilities import ClientCapabilities
from .client_debug import ClientDebug
from .serial_manager import SerialManager
from .wireviz import WireViz from .wireviz import WireViz
from .wireviz_manager import WireVizManager
__all__ = [ __all__ = [
"ArduinoBoard", "ArduinoBoard",
@ -12,5 +15,8 @@ __all__ = [
"ArduinoLibrary", "ArduinoLibrary",
"ArduinoSketch", "ArduinoSketch",
"WireViz", "WireViz",
"WireVizManager", "ClientDebug",
] "ClientCapabilities",
"SerialManager",
"CircularBuffer",
]

View File

@ -3,10 +3,10 @@ import asyncio
import json import json
import logging import logging
import subprocess import subprocess
from typing import List, Dict, Any, Optional from typing import Any
from fastmcp import Context from fastmcp import Context
from fastmcp.contrib.mcp_mixin import MCPMixin, mcp_tool, mcp_resource from fastmcp.contrib.mcp_mixin import MCPMixin, mcp_resource, mcp_tool
from mcp.types import ToolAnnotations from mcp.types import ToolAnnotations
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@ -123,7 +123,7 @@ Common troubleshooting steps:
self, self,
ctx: Context | None, ctx: Context | None,
query: str query: str
) -> Dict[str, Any]: ) -> dict[str, Any]:
"""Search for Arduino board definitions""" """Search for Arduino board definitions"""
try: try:
@ -200,7 +200,7 @@ Common troubleshooting steps:
self, self,
ctx: Context | None, ctx: Context | None,
core_spec: str core_spec: str
) -> Dict[str, Any]: ) -> dict[str, Any]:
"""Install an Arduino board core/platform """Install an Arduino board core/platform
Args: Args:
@ -328,7 +328,7 @@ Common troubleshooting steps:
async def list_cores( async def list_cores(
self, self,
ctx: Context | None = None ctx: Context | None = None
) -> Dict[str, Any]: ) -> dict[str, Any]:
"""List all installed Arduino board cores""" """List all installed Arduino board cores"""
try: try:
@ -405,7 +405,7 @@ Common troubleshooting steps:
async def install_esp32( async def install_esp32(
self, self,
ctx: Context | None = None ctx: Context | None = None
) -> Dict[str, Any]: ) -> dict[str, Any]:
"""Install ESP32 board support with automatic board package URL configuration""" """Install ESP32 board support with automatic board package URL configuration"""
try: try:
@ -600,7 +600,7 @@ Common troubleshooting steps:
async def update_cores( async def update_cores(
self, self,
ctx: Context | None = None ctx: Context | None = None
) -> Dict[str, Any]: ) -> dict[str, Any]:
"""Update all installed Arduino cores""" """Update all installed Arduino cores"""
try: try:
@ -662,4 +662,4 @@ Common troubleshooting steps:
return {"error": f"Update timed out after {self.config.command_timeout * 3} seconds"} return {"error": f"Update timed out after {self.config.command_timeout * 3} seconds"}
except Exception as e: except Exception as e:
log.exception(f"Core update failed: {e}") log.exception(f"Core update failed: {e}")
return {"error": str(e)} return {"error": str(e)}

View File

@ -4,11 +4,10 @@ Provides board details, discovery, and attachment features
""" """
import json import json
import os
from typing import List, Dict, Optional, Any
from pathlib import Path
import subprocess
import logging import logging
import subprocess
from pathlib import Path
from typing import Any
from fastmcp import Context from fastmcp import Context
from fastmcp.contrib.mcp_mixin import MCPMixin, mcp_tool from fastmcp.contrib.mcp_mixin import MCPMixin, mcp_tool
@ -26,7 +25,7 @@ class ArduinoBoardsAdvanced(MCPMixin):
self.cli_path = config.arduino_cli_path self.cli_path = config.arduino_cli_path
self.sketch_dir = Path(config.sketch_dir).expanduser() self.sketch_dir = Path(config.sketch_dir).expanduser()
async def _run_arduino_cli(self, args: List[str], capture_output: bool = True) -> Dict[str, Any]: async def _run_arduino_cli(self, args: list[str], capture_output: bool = True) -> dict[str, Any]:
"""Run Arduino CLI command and return result""" """Run Arduino CLI command and return result"""
cmd = [self.cli_path] + args cmd = [self.cli_path] + args
@ -80,7 +79,7 @@ class ArduinoBoardsAdvanced(MCPMixin):
list_programmers: bool = Field(False, description="Include available programmers"), list_programmers: bool = Field(False, description="Include available programmers"),
show_properties: bool = Field(True, description="Show all board properties"), show_properties: bool = Field(True, description="Show all board properties"),
ctx: Context = None ctx: Context = None
) -> Dict[str, Any]: ) -> dict[str, Any]:
"""Get comprehensive details about a specific board""" """Get comprehensive details about a specific board"""
args = ["board", "details", "--fqbn", fqbn] args = ["board", "details", "--fqbn", fqbn]
@ -154,10 +153,10 @@ class ArduinoBoardsAdvanced(MCPMixin):
) )
async def list_all_boards( async def list_all_boards(
self, self,
search_filter: Optional[str] = Field(None, description="Filter boards by name or FQBN"), search_filter: str | None = Field(None, description="Filter boards by name or FQBN"),
show_hidden: bool = Field(False, description="Show hidden boards"), show_hidden: bool = Field(False, description="Show hidden boards"),
ctx: Context = None ctx: Context = None
) -> Dict[str, Any]: ) -> dict[str, Any]:
"""List all available boards from all installed platforms""" """List all available boards from all installed platforms"""
args = ["board", "listall"] args = ["board", "listall"]
@ -228,11 +227,11 @@ class ArduinoBoardsAdvanced(MCPMixin):
async def attach_board( async def attach_board(
self, self,
sketch_name: str = Field(..., description="Sketch name to attach board to"), sketch_name: str = Field(..., description="Sketch name to attach board to"),
port: Optional[str] = Field(None, description="Port where board is connected"), port: str | None = Field(None, description="Port where board is connected"),
fqbn: Optional[str] = Field(None, description="Board FQBN"), fqbn: str | None = Field(None, description="Board FQBN"),
discovery_timeout: int = Field(5, description="Discovery timeout in seconds"), discovery_timeout: int = Field(5, description="Discovery timeout in seconds"),
ctx: Context = None ctx: Context = None
) -> Dict[str, Any]: ) -> dict[str, Any]:
"""Attach a board to a sketch for persistent association""" """Attach a board to a sketch for persistent association"""
sketch_path = self.sketch_dir / sketch_name sketch_path = self.sketch_dir / sketch_name
@ -259,7 +258,7 @@ class ArduinoBoardsAdvanced(MCPMixin):
attached_info = {} attached_info = {}
if sketch_json_path.exists(): if sketch_json_path.exists():
with open(sketch_json_path, 'r') as f: with open(sketch_json_path) as f:
sketch_data = json.load(f) sketch_data = json.load(f)
attached_info = { attached_info = {
"cpu": sketch_data.get("cpu"), "cpu": sketch_data.get("cpu"),
@ -284,7 +283,7 @@ class ArduinoBoardsAdvanced(MCPMixin):
self, self,
query: str = Field(..., description="Search query for boards"), query: str = Field(..., description="Search query for boards"),
ctx: Context = None ctx: Context = None
) -> Dict[str, Any]: ) -> dict[str, Any]:
"""Search for boards in the online package index""" """Search for boards in the online package index"""
args = ["board", "search", query] args = ["board", "search", query]
@ -334,7 +333,7 @@ class ArduinoBoardsAdvanced(MCPMixin):
port: str = Field(..., description="Port to identify board on"), port: str = Field(..., description="Port to identify board on"),
timeout: int = Field(10, description="Timeout in seconds"), timeout: int = Field(10, description="Timeout in seconds"),
ctx: Context = None ctx: Context = None
) -> Dict[str, Any]: ) -> dict[str, Any]:
"""Identify board connected to a specific port""" """Identify board connected to a specific port"""
# Arduino CLI board list doesn't filter by port, it lists all ports # Arduino CLI board list doesn't filter by port, it lists all ports
# We'll get all boards and filter for the requested port # We'll get all boards and filter for the requested port
@ -396,4 +395,4 @@ class ArduinoBoardsAdvanced(MCPMixin):
"success": False, "success": False,
"error": f"No device found on port {port}", "error": f"No device found on port {port}",
"suggestion": "Check connection and port permissions" "suggestion": "Check connection and port permissions"
} }

View File

@ -4,13 +4,12 @@ Provides advanced compile options, build analysis, and cache management
""" """
import json import json
import logging
import os import os
import shutil import shutil
import re
from typing import List, Dict, Optional, Any
from pathlib import Path
import subprocess import subprocess
import logging from pathlib import Path
from typing import Any
from fastmcp import Context from fastmcp import Context
from fastmcp.contrib.mcp_mixin import MCPMixin, mcp_tool from fastmcp.contrib.mcp_mixin import MCPMixin, mcp_tool
@ -29,7 +28,7 @@ class ArduinoCompileAdvanced(MCPMixin):
self.sketch_dir = Path(config.sketch_dir).expanduser() self.sketch_dir = Path(config.sketch_dir).expanduser()
self.build_cache_dir = Path.home() / ".arduino" / "build-cache" self.build_cache_dir = Path.home() / ".arduino" / "build-cache"
async def _run_arduino_cli(self, args: List[str], capture_output: bool = True) -> Dict[str, Any]: async def _run_arduino_cli(self, args: list[str], capture_output: bool = True) -> dict[str, Any]:
"""Run Arduino CLI command and return result""" """Run Arduino CLI command and return result"""
cmd = [self.cli_path] + args cmd = [self.cli_path] + args
@ -83,22 +82,22 @@ class ArduinoCompileAdvanced(MCPMixin):
async def compile_advanced( async def compile_advanced(
self, self,
sketch_name: str = Field(..., description="Name of the sketch to compile"), sketch_name: str = Field(..., description="Name of the sketch to compile"),
fqbn: Optional[str] = Field(None, description="Board FQBN (auto-detect if not provided)"), fqbn: str | None = Field(None, description="Board FQBN (auto-detect if not provided)"),
build_properties: Optional[Dict[str, str]] = Field(None, description="Custom build properties"), build_properties: dict[str, str] | None = Field(None, description="Custom build properties"),
build_cache_path: Optional[str] = Field(None, description="Custom build cache directory"), build_cache_path: str | None = Field(None, description="Custom build cache directory"),
build_path: Optional[str] = Field(None, description="Custom build output directory"), build_path: str | None = Field(None, description="Custom build output directory"),
export_binaries: bool = Field(False, description="Export compiled binaries to sketch folder"), export_binaries: bool = Field(False, description="Export compiled binaries to sketch folder"),
libraries: Optional[List[str]] = Field(None, description="Additional libraries to include"), libraries: list[str] | None = Field(None, description="Additional libraries to include"),
optimize_for_debug: bool = Field(False, description="Optimize for debugging"), optimize_for_debug: bool = Field(False, description="Optimize for debugging"),
preprocess_only: bool = Field(False, description="Only run preprocessor"), preprocess_only: bool = Field(False, description="Only run preprocessor"),
show_properties: bool = Field(False, description="Show all build properties"), show_properties: bool = Field(False, description="Show all build properties"),
verbose: bool = Field(False, description="Verbose output"), verbose: bool = Field(False, description="Verbose output"),
warnings: str = Field("default", description="Warning level: none, default, more, all"), warnings: str = Field("default", description="Warning level: none, default, more, all"),
vid_pid: Optional[str] = Field(None, description="USB VID/PID for board detection"), vid_pid: str | None = Field(None, description="USB VID/PID for board detection"),
jobs: Optional[int] = Field(None, description="Number of parallel jobs"), jobs: int | None = Field(None, description="Number of parallel jobs"),
clean: bool = Field(False, description="Clean build directory before compile"), clean: bool = Field(False, description="Clean build directory before compile"),
ctx: Context = None ctx: Context = None
) -> Dict[str, Any]: ) -> dict[str, Any]:
""" """
Compile Arduino sketch with advanced options Compile Arduino sketch with advanced options
@ -257,11 +256,11 @@ class ArduinoCompileAdvanced(MCPMixin):
async def analyze_size( async def analyze_size(
self, self,
sketch_name: str = Field(..., description="Name of the sketch"), sketch_name: str = Field(..., description="Name of the sketch"),
fqbn: Optional[str] = Field(None, description="Board FQBN"), fqbn: str | None = Field(None, description="Board FQBN"),
build_path: Optional[str] = Field(None, description="Build directory path"), build_path: str | None = Field(None, description="Build directory path"),
detailed: bool = Field(True, description="Show detailed section breakdown"), detailed: bool = Field(True, description="Show detailed section breakdown"),
ctx: Context = None ctx: Context = None
) -> Dict[str, Any]: ) -> dict[str, Any]:
"""Analyze compiled sketch size and memory usage""" """Analyze compiled sketch size and memory usage"""
# First compile to ensure we have a binary # First compile to ensure we have a binary
@ -375,7 +374,7 @@ class ArduinoCompileAdvanced(MCPMixin):
except FileNotFoundError: except FileNotFoundError:
return {"success": False, "error": "Size analysis tool not found. Install avr-size or xtensa-esp32-elf-size"} return {"success": False, "error": "Size analysis tool not found. Install avr-size or xtensa-esp32-elf-size"}
def _get_board_memory_limits(self, fqbn: Optional[str]) -> Dict[str, int]: def _get_board_memory_limits(self, fqbn: str | None) -> dict[str, int]:
"""Get memory limits for common boards""" """Get memory limits for common boards"""
memory_map = { memory_map = {
"arduino:avr:uno": {"flash": 32256, "ram": 2048}, "arduino:avr:uno": {"flash": 32256, "ram": 2048},
@ -408,7 +407,7 @@ class ArduinoCompileAdvanced(MCPMixin):
async def clean_cache( async def clean_cache(
self, self,
ctx: Context = None ctx: Context = None
) -> Dict[str, Any]: ) -> dict[str, Any]:
"""Clean Arduino build cache to free disk space""" """Clean Arduino build cache to free disk space"""
args = ["cache", "clean"] args = ["cache", "clean"]
@ -441,9 +440,9 @@ class ArduinoCompileAdvanced(MCPMixin):
async def show_build_properties( async def show_build_properties(
self, self,
fqbn: str = Field(..., description="Board FQBN"), fqbn: str = Field(..., description="Board FQBN"),
sketch_name: Optional[str] = Field(None, description="Sketch to get properties for"), sketch_name: str | None = Field(None, description="Sketch to get properties for"),
ctx: Context = None ctx: Context = None
) -> Dict[str, Any]: ) -> dict[str, Any]:
"""Show all build properties used during compilation""" """Show all build properties used during compilation"""
args = ["compile", "--fqbn", fqbn, "--show-properties"] args = ["compile", "--fqbn", fqbn, "--show-properties"]
@ -512,10 +511,10 @@ class ArduinoCompileAdvanced(MCPMixin):
async def export_binary( async def export_binary(
self, self,
sketch_name: str = Field(..., description="Name of the sketch"), sketch_name: str = Field(..., description="Name of the sketch"),
output_dir: Optional[str] = Field(None, description="Directory to export to (default: sketch folder)"), output_dir: str | None = Field(None, description="Directory to export to (default: sketch folder)"),
fqbn: Optional[str] = Field(None, description="Board FQBN"), fqbn: str | None = Field(None, description="Board FQBN"),
ctx: Context = None ctx: Context = None
) -> Dict[str, Any]: ) -> dict[str, Any]:
"""Export compiled binary files (.hex, .bin, .elf)""" """Export compiled binary files (.hex, .bin, .elf)"""
# Compile with export flag # Compile with export flag
@ -556,4 +555,4 @@ class ArduinoCompileAdvanced(MCPMixin):
"success": True, "success": True,
"sketch": sketch_name, "sketch": sketch_name,
"exported_files": result.get("exported_binaries", []) "exported_files": result.get("exported_binaries", [])
} }

View File

@ -1,15 +1,13 @@
"""Arduino Debug component using PyArduinoDebug for GDB-like debugging""" """Arduino Debug component using PyArduinoDebug for GDB-like debugging"""
import asyncio import asyncio
import json
import logging import logging
import subprocess
import shutil import shutil
from pathlib import Path
from typing import Dict, Any, Optional, List
from enum import Enum from enum import Enum
from pathlib import Path
from typing import Any
from fastmcp import Context from fastmcp import Context
from fastmcp.contrib.mcp_mixin import MCPMixin, mcp_tool, mcp_resource from fastmcp.contrib.mcp_mixin import MCPMixin, mcp_resource, mcp_tool
from mcp.types import ToolAnnotations from mcp.types import ToolAnnotations
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@ -33,7 +31,7 @@ class DebugCommand(str, Enum):
class BreakpointRequest(BaseModel): class BreakpointRequest(BaseModel):
"""Request model for setting breakpoints""" """Request model for setting breakpoints"""
location: str = Field(..., description="Function name or line number (file:line)") location: str = Field(..., description="Function name or line number (file:line)")
condition: Optional[str] = Field(None, description="Conditional expression for breakpoint") condition: str | None = Field(None, description="Conditional expression for breakpoint")
temporary: bool = Field(False, description="Whether breakpoint is temporary (deleted after hit)") temporary: bool = Field(False, description="Whether breakpoint is temporary (deleted after hit)")
@ -88,7 +86,7 @@ class ArduinoDebug(MCPMixin):
port: str, port: str,
board_fqbn: str = "", board_fqbn: str = "",
gdb_port: int = 4242 gdb_port: int = 4242
) -> Dict[str, Any]: ) -> dict[str, Any]:
"""Start a debugging session for an Arduino sketch """Start a debugging session for an Arduino sketch
Args: Args:
@ -246,9 +244,9 @@ class ArduinoDebug(MCPMixin):
ctx: Context | None, ctx: Context | None,
session_id: str, session_id: str,
location: str, location: str,
condition: Optional[str] = None, condition: str | None = None,
temporary: bool = False temporary: bool = False
) -> Dict[str, Any]: ) -> dict[str, Any]:
"""Set a breakpoint in the debugging session """Set a breakpoint in the debugging session
Args: Args:
@ -329,7 +327,7 @@ class ArduinoDebug(MCPMixin):
auto_watch: bool = True, auto_watch: bool = True,
auto_mode: bool = False, auto_mode: bool = False,
auto_strategy: str = "continue" auto_strategy: str = "continue"
) -> Dict[str, Any]: ) -> dict[str, Any]:
"""Interactive debugging with optional user elicitation at breakpoints """Interactive debugging with optional user elicitation at breakpoints
USAGE GUIDANCE FOR AI MODELS: USAGE GUIDANCE FOR AI MODELS:
@ -551,7 +549,7 @@ class ArduinoDebug(MCPMixin):
ctx: Context | None, ctx: Context | None,
session_id: str, session_id: str,
command: str = "continue" command: str = "continue"
) -> Dict[str, Any]: ) -> dict[str, Any]:
"""Run or continue execution in debug session """Run or continue execution in debug session
Args: Args:
@ -626,7 +624,7 @@ class ArduinoDebug(MCPMixin):
ctx: Context | None, ctx: Context | None,
session_id: str, session_id: str,
expression: str expression: str
) -> Dict[str, Any]: ) -> dict[str, Any]:
"""Print variable value or evaluate expression """Print variable value or evaluate expression
Args: Args:
@ -679,7 +677,7 @@ class ArduinoDebug(MCPMixin):
ctx: Context | None, ctx: Context | None,
session_id: str, session_id: str,
full: bool = False full: bool = False
) -> Dict[str, Any]: ) -> dict[str, Any]:
"""Show call stack backtrace """Show call stack backtrace
Args: Args:
@ -730,7 +728,7 @@ class ArduinoDebug(MCPMixin):
self, self,
ctx: Context | None, ctx: Context | None,
session_id: str session_id: str
) -> Dict[str, Any]: ) -> dict[str, Any]:
"""List all breakpoints with their status """List all breakpoints with their status
Args: Args:
@ -809,9 +807,9 @@ class ArduinoDebug(MCPMixin):
self, self,
ctx: Context | None, ctx: Context | None,
session_id: str, session_id: str,
breakpoint_id: Optional[str] = None, breakpoint_id: str | None = None,
delete_all: bool = False delete_all: bool = False
) -> Dict[str, Any]: ) -> dict[str, Any]:
"""Delete one or all breakpoints """Delete one or all breakpoints
Args: Args:
@ -877,7 +875,7 @@ class ArduinoDebug(MCPMixin):
session_id: str, session_id: str,
breakpoint_id: str, breakpoint_id: str,
enable: bool = True enable: bool = True
) -> Dict[str, Any]: ) -> dict[str, Any]:
"""Enable or disable a breakpoint """Enable or disable a breakpoint
Args: Args:
@ -930,7 +928,7 @@ class ArduinoDebug(MCPMixin):
session_id: str, session_id: str,
breakpoint_id: str, breakpoint_id: str,
condition: str condition: str
) -> Dict[str, Any]: ) -> dict[str, Any]:
"""Set or modify a breakpoint condition """Set or modify a breakpoint condition
Args: Args:
@ -994,8 +992,8 @@ class ArduinoDebug(MCPMixin):
self, self,
ctx: Context | None, ctx: Context | None,
session_id: str, session_id: str,
filename: Optional[str] = None filename: str | None = None
) -> Dict[str, Any]: ) -> dict[str, Any]:
"""Save breakpoints to a file for later restoration """Save breakpoints to a file for later restoration
Args: Args:
@ -1061,7 +1059,7 @@ class ArduinoDebug(MCPMixin):
ctx: Context | None, ctx: Context | None,
session_id: str, session_id: str,
filename: str filename: str
) -> Dict[str, Any]: ) -> dict[str, Any]:
"""Restore breakpoints from a saved file """Restore breakpoints from a saved file
Args: Args:
@ -1091,7 +1089,7 @@ class ArduinoDebug(MCPMixin):
metadata_file = Path(filename).with_suffix('.meta.json') metadata_file = Path(filename).with_suffix('.meta.json')
if metadata_file.exists(): if metadata_file.exists():
import json import json
with open(metadata_file, 'r') as f: with open(metadata_file) as f:
metadata = json.load(f) metadata = json.load(f)
session['breakpoints'] = metadata.get('breakpoints', []) session['breakpoints'] = metadata.get('breakpoints', [])
@ -1123,7 +1121,7 @@ class ArduinoDebug(MCPMixin):
ctx: Context | None, ctx: Context | None,
session_id: str, session_id: str,
expression: str expression: str
) -> Dict[str, Any]: ) -> dict[str, Any]:
"""Add a watch expression to monitor changes """Add a watch expression to monitor changes
Args: Args:
@ -1179,7 +1177,7 @@ class ArduinoDebug(MCPMixin):
address: str, address: str,
count: int = 16, count: int = 16,
format: str = "hex" format: str = "hex"
) -> Dict[str, Any]: ) -> dict[str, Any]:
"""Examine memory contents """Examine memory contents
Args: Args:
@ -1238,7 +1236,7 @@ class ArduinoDebug(MCPMixin):
self, self,
ctx: Context | None, ctx: Context | None,
session_id: str session_id: str
) -> Dict[str, Any]: ) -> dict[str, Any]:
"""Stop and cleanup debug session """Stop and cleanup debug session
Args: Args:
@ -1293,7 +1291,7 @@ class ArduinoDebug(MCPMixin):
self, self,
ctx: Context | None, ctx: Context | None,
session_id: str session_id: str
) -> Dict[str, Any]: ) -> dict[str, Any]:
"""Show current CPU register values """Show current CPU register values
Args: Args:
@ -1343,7 +1341,7 @@ class ArduinoDebug(MCPMixin):
return line.strip() return line.strip()
return "unknown location" return "unknown location"
async def _send_gdb_command(self, session: Dict, command: str) -> str: async def _send_gdb_command(self, session: dict, command: str) -> str:
"""Send command to GDB process and return output""" """Send command to GDB process and return output"""
process = session['process'] process = session['process']
@ -1371,4 +1369,4 @@ class ArduinoDebug(MCPMixin):
except asyncio.TimeoutError: except asyncio.TimeoutError:
pass pass
return output.strip() return output.strip()

View File

@ -4,12 +4,12 @@ Provides dependency checking, version management, and library operations
""" """
import json import json
import logging
import os import os
import re import re
from typing import List, Dict, Optional, Any
from pathlib import Path
import subprocess import subprocess
import logging from pathlib import Path
from typing import Any
from fastmcp import Context from fastmcp import Context
from fastmcp.contrib.mcp_mixin import MCPMixin, mcp_tool from fastmcp.contrib.mcp_mixin import MCPMixin, mcp_tool
@ -27,7 +27,7 @@ class ArduinoLibrariesAdvanced(MCPMixin):
self.cli_path = config.arduino_cli_path self.cli_path = config.arduino_cli_path
self.sketch_dir = Path(config.sketch_dir).expanduser() self.sketch_dir = Path(config.sketch_dir).expanduser()
async def _run_arduino_cli(self, args: List[str], capture_output: bool = True) -> Dict[str, Any]: async def _run_arduino_cli(self, args: list[str], capture_output: bool = True) -> dict[str, Any]:
"""Run Arduino CLI command and return result""" """Run Arduino CLI command and return result"""
cmd = [self.cli_path] + args cmd = [self.cli_path] + args
@ -81,10 +81,10 @@ class ArduinoLibrariesAdvanced(MCPMixin):
async def check_dependencies( async def check_dependencies(
self, self,
library_name: str = Field(..., description="Library name to check dependencies for"), library_name: str = Field(..., description="Library name to check dependencies for"),
fqbn: Optional[str] = Field(None, description="Board FQBN to check compatibility"), fqbn: str | None = Field(None, description="Board FQBN to check compatibility"),
check_installed: bool = Field(True, description="Check if dependencies are installed"), check_installed: bool = Field(True, description="Check if dependencies are installed"),
ctx: Context = None ctx: Context = None
) -> Dict[str, Any]: ) -> dict[str, Any]:
""" """
Check library dependencies and identify missing libraries Check library dependencies and identify missing libraries
@ -180,10 +180,10 @@ class ArduinoLibrariesAdvanced(MCPMixin):
async def download_library( async def download_library(
self, self,
library_name: str = Field(..., description="Library name to download"), library_name: str = Field(..., description="Library name to download"),
version: Optional[str] = Field(None, description="Specific version to download"), version: str | None = Field(None, description="Specific version to download"),
download_dir: Optional[str] = Field(None, description="Directory to download to"), download_dir: str | None = Field(None, description="Directory to download to"),
ctx: Context = None ctx: Context = None
) -> Dict[str, Any]: ) -> dict[str, Any]:
"""Download library archives without installation""" """Download library archives without installation"""
args = ["lib", "download", library_name] args = ["lib", "download", library_name]
@ -220,10 +220,10 @@ class ArduinoLibrariesAdvanced(MCPMixin):
self, self,
updatable: bool = Field(False, description="Show only updatable libraries"), updatable: bool = Field(False, description="Show only updatable libraries"),
all_versions: bool = Field(False, description="Show all available versions"), all_versions: bool = Field(False, description="Show all available versions"),
fqbn: Optional[str] = Field(None, description="Filter by board compatibility"), fqbn: str | None = Field(None, description="Filter by board compatibility"),
name_filter: Optional[str] = Field(None, description="Filter by library name pattern"), name_filter: str | None = Field(None, description="Filter by library name pattern"),
ctx: Context = None ctx: Context = None
) -> Dict[str, Any]: ) -> dict[str, Any]:
"""List installed libraries with detailed information""" """List installed libraries with detailed information"""
args = ["lib", "list"] args = ["lib", "list"]
@ -298,9 +298,9 @@ class ArduinoLibrariesAdvanced(MCPMixin):
) )
async def upgrade_libraries( async def upgrade_libraries(
self, self,
library_names: Optional[List[str]] = Field(None, description="Specific libraries to upgrade (None = all)"), library_names: list[str] | None = Field(None, description="Specific libraries to upgrade (None = all)"),
ctx: Context = None ctx: Context = None
) -> Dict[str, Any]: ) -> dict[str, Any]:
"""Upgrade one or more libraries to their latest versions""" """Upgrade one or more libraries to their latest versions"""
args = ["lib", "upgrade"] args = ["lib", "upgrade"]
@ -353,7 +353,7 @@ class ArduinoLibrariesAdvanced(MCPMixin):
async def update_index( async def update_index(
self, self,
ctx: Context = None ctx: Context = None
) -> Dict[str, Any]: ) -> dict[str, Any]:
"""Update Arduino libraries and boards package index""" """Update Arduino libraries and boards package index"""
# Update libraries index # Update libraries index
lib_result = await self._run_arduino_cli(["lib", "update-index"]) lib_result = await self._run_arduino_cli(["lib", "update-index"])
@ -379,7 +379,7 @@ class ArduinoLibrariesAdvanced(MCPMixin):
async def check_outdated( async def check_outdated(
self, self,
ctx: Context = None ctx: Context = None
) -> Dict[str, Any]: ) -> dict[str, Any]:
"""Check for outdated libraries and cores""" """Check for outdated libraries and cores"""
result = await self._run_arduino_cli(["outdated"]) result = await self._run_arduino_cli(["outdated"])
@ -427,11 +427,11 @@ class ArduinoLibrariesAdvanced(MCPMixin):
) )
async def list_examples( async def list_examples(
self, self,
library_name: Optional[str] = Field(None, description="Filter examples by library name"), library_name: str | None = Field(None, description="Filter examples by library name"),
fqbn: Optional[str] = Field(None, description="Filter by board compatibility"), fqbn: str | None = Field(None, description="Filter by board compatibility"),
with_description: bool = Field(True, description="Include example descriptions"), with_description: bool = Field(True, description="Include example descriptions"),
ctx: Context = None ctx: Context = None
) -> Dict[str, Any]: ) -> dict[str, Any]:
"""List all examples from installed libraries""" """List all examples from installed libraries"""
args = ["lib", "examples"] args = ["lib", "examples"]
@ -466,7 +466,7 @@ class ArduinoLibrariesAdvanced(MCPMixin):
try: try:
sketch_file = Path(example_info["sketch_path"]) sketch_file = Path(example_info["sketch_path"])
if sketch_file.exists(): if sketch_file.exists():
with open(sketch_file, 'r') as f: with open(sketch_file) as f:
# Read first comment block as description # Read first comment block as description
content = f.read(500) # First 500 chars content = f.read(500) # First 500 chars
if content.startswith("/*"): if content.startswith("/*"):
@ -504,11 +504,11 @@ class ArduinoLibrariesAdvanced(MCPMixin):
) )
async def install_missing_dependencies( async def install_missing_dependencies(
self, self,
library_name: Optional[str] = Field(None, description="Library to install dependencies for"), library_name: str | None = Field(None, description="Library to install dependencies for"),
sketch_name: Optional[str] = Field(None, description="Sketch to analyze and install dependencies for"), sketch_name: str | None = Field(None, description="Sketch to analyze and install dependencies for"),
dry_run: bool = Field(False, description="Only show what would be installed"), dry_run: bool = Field(False, description="Only show what would be installed"),
ctx: Context = None ctx: Context = None
) -> Dict[str, Any]: ) -> dict[str, Any]:
"""Install all missing dependencies automatically""" """Install all missing dependencies automatically"""
to_install = [] to_install = []
@ -524,7 +524,7 @@ class ArduinoLibrariesAdvanced(MCPMixin):
sketch_path = self.sketch_dir / sketch_name / f"{sketch_name}.ino" sketch_path = self.sketch_dir / sketch_name / f"{sketch_name}.ino"
if sketch_path.exists(): if sketch_path.exists():
# Parse includes from sketch # Parse includes from sketch
with open(sketch_path, 'r') as f: with open(sketch_path) as f:
content = f.read() content = f.read()
includes = re.findall(r'#include\s+[<"]([^>"]+)[>"]', content) includes = re.findall(r'#include\s+[<"]([^>"]+)[>"]', content)
@ -586,4 +586,4 @@ class ArduinoLibrariesAdvanced(MCPMixin):
"failed_count": len(failed), "failed_count": len(failed),
"failed_libraries": failed, "failed_libraries": failed,
"all_dependencies_satisfied": len(failed) == 0 "all_dependencies_satisfied": len(failed) == 0
} }

View File

@ -4,10 +4,10 @@ import json
import logging import logging
import subprocess import subprocess
from pathlib import Path from pathlib import Path
from typing import List, Dict, Any, Optional from typing import Any
from fastmcp import Context from fastmcp import Context
from fastmcp.contrib.mcp_mixin import MCPMixin, mcp_tool, mcp_resource from fastmcp.contrib.mcp_mixin import MCPMixin, mcp_resource, mcp_tool
from mcp.types import ToolAnnotations from mcp.types import ToolAnnotations
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@ -71,7 +71,7 @@ class ArduinoLibrary(MCPMixin):
ctx: Context | None, ctx: Context | None,
query: str, query: str,
limit: int = 10 limit: int = 10
) -> Dict[str, Any]: ) -> dict[str, Any]:
"""Search for Arduino libraries online""" """Search for Arduino libraries online"""
try: try:
@ -157,8 +157,8 @@ class ArduinoLibrary(MCPMixin):
self, self,
ctx: Context | None, ctx: Context | None,
library_name: str, library_name: str,
version: Optional[str] = None version: str | None = None
) -> Dict[str, Any]: ) -> dict[str, Any]:
"""Install an Arduino library""" """Install an Arduino library"""
try: try:
@ -282,7 +282,7 @@ class ArduinoLibrary(MCPMixin):
self, self,
ctx: Context | None, ctx: Context | None,
library_name: str library_name: str
) -> Dict[str, Any]: ) -> dict[str, Any]:
"""Uninstall an Arduino library""" """Uninstall an Arduino library"""
try: try:
@ -333,7 +333,7 @@ class ArduinoLibrary(MCPMixin):
self, self,
ctx: Context | None, ctx: Context | None,
library_name: str library_name: str
) -> Dict[str, Any]: ) -> dict[str, Any]:
"""List examples from an installed Arduino library""" """List examples from an installed Arduino library"""
try: try:
@ -401,7 +401,7 @@ class ArduinoLibrary(MCPMixin):
log.exception(f"Failed to list library examples: {e}") log.exception(f"Failed to list library examples: {e}")
return {"error": str(e)} return {"error": str(e)}
async def _get_installed_libraries(self) -> List[Dict[str, Any]]: async def _get_installed_libraries(self) -> list[dict[str, Any]]:
"""Get list of installed libraries""" """Get list of installed libraries"""
try: try:
cmd = [ cmd = [
@ -455,4 +455,4 @@ class ArduinoLibrary(MCPMixin):
return description or "No description available" return description or "No description available"
except Exception: except Exception:
return "No description available" return "No description available"

View File

@ -5,19 +5,13 @@ Provides serial communication with cursor-based data streaming
import asyncio import asyncio
import os import os
import uuid
from datetime import datetime
from typing import Dict, List, Optional, Any
from dataclasses import dataclass, asdict
from enum import Enum
from fastmcp import Context from fastmcp import Context
from fastmcp.contrib.mcp_mixin import MCPMixin, mcp_tool, mcp_resource from fastmcp.contrib.mcp_mixin import MCPMixin, mcp_resource, mcp_tool
from pydantic import BaseModel, Field from pydantic import Field
from .serial_manager import SerialConnectionManager, SerialConnection, ConnectionState
from .circular_buffer import CircularSerialBuffer, SerialDataType, SerialDataEntry
from .circular_buffer import CircularSerialBuffer, SerialDataType
from .serial_manager import SerialConnectionManager
# Use CircularSerialBuffer directly # Use CircularSerialBuffer directly
SerialDataBuffer = CircularSerialBuffer SerialDataBuffer = CircularSerialBuffer
@ -37,7 +31,7 @@ class ArduinoSerial(MCPMixin):
buffer_size = max(100, min(buffer_size, 1000000)) # Between 100 and 1M entries buffer_size = max(100, min(buffer_size, 1000000)) # Between 100 and 1M entries
self.data_buffer = CircularSerialBuffer(max_size=buffer_size) self.data_buffer = CircularSerialBuffer(max_size=buffer_size)
self.active_monitors: Dict[str, asyncio.Task] = {} self.active_monitors: dict[str, asyncio.Task] = {}
self._initialized = False self._initialized = False
# Log buffer configuration # Log buffer configuration
@ -202,10 +196,10 @@ class ArduinoSerial(MCPMixin):
@mcp_tool(name="serial_read", description="Read serial data using cursor-based pagination") @mcp_tool(name="serial_read", description="Read serial data using cursor-based pagination")
async def read( async def read(
self, self,
cursor_id: Optional[str] = Field(None, description="Cursor ID for pagination"), cursor_id: str | None = Field(None, description="Cursor ID for pagination"),
port: Optional[str] = Field(None, description="Filter by port"), port: str | None = Field(None, description="Filter by port"),
limit: int = Field(100, description="Maximum entries to return"), limit: int = Field(100, description="Maximum entries to return"),
type_filter: Optional[str] = Field(None, description="Filter by data type"), type_filter: str | None = Field(None, description="Filter by data type"),
create_cursor: bool = Field(False, description="Create new cursor if not provided"), create_cursor: bool = Field(False, description="Create new cursor if not provided"),
start_from: str = Field("oldest", description="Where to start cursor: oldest, newest, next"), start_from: str = Field("oldest", description="Where to start cursor: oldest, newest, next"),
auto_recover: bool = Field(True, description="Auto-recover invalid cursors"), auto_recover: bool = Field(True, description="Auto-recover invalid cursors"),
@ -274,7 +268,7 @@ class ArduinoSerial(MCPMixin):
@mcp_tool(name="serial_clear_buffer", description="Clear serial data buffer") @mcp_tool(name="serial_clear_buffer", description="Clear serial data buffer")
async def clear_buffer( async def clear_buffer(
self, self,
port: Optional[str] = Field(None, description="Clear specific port or all if None"), port: str | None = Field(None, description="Clear specific port or all if None"),
ctx: Context = None ctx: Context = None
) -> dict: ) -> dict:
"""Clear serial data buffer""" """Clear serial data buffer"""
@ -365,4 +359,4 @@ class ArduinoSerial(MCPMixin):
return {"success": False, "error": "Size must be between 100 and 1,000,000"} return {"success": False, "error": "Size must be between 100 and 1,000,000"}
result = self.data_buffer.resize_buffer(new_size) result = self.data_buffer.resize_buffer(new_size)
return {"success": True, **result} return {"success": True, **result}

View File

@ -3,10 +3,10 @@ import logging
import os import os
import subprocess import subprocess
from pathlib import Path from pathlib import Path
from typing import List, Dict, Any, Optional from typing import Any
from fastmcp import Context from fastmcp import Context
from fastmcp.contrib.mcp_mixin import MCPMixin, mcp_tool, mcp_resource from fastmcp.contrib.mcp_mixin import MCPMixin, mcp_resource, mcp_tool
from mcp.types import ToolAnnotations from mcp.types import ToolAnnotations
from pydantic import BaseModel, Field, field_validator from pydantic import BaseModel, Field, field_validator
@ -56,7 +56,7 @@ class ArduinoSketch(MCPMixin):
self, self,
ctx: Context | None, ctx: Context | None,
sketch_name: str sketch_name: str
) -> Dict[str, Any]: ) -> dict[str, Any]:
"""Create a new Arduino sketch directory and .ino file with boilerplate code""" """Create a new Arduino sketch directory and .ino file with boilerplate code"""
try: try:
@ -174,7 +174,7 @@ void loop() {{
ctx: Context | None, ctx: Context | None,
sketch_name: str, sketch_name: str,
board_fqbn: str = "" board_fqbn: str = ""
) -> Dict[str, Any]: ) -> dict[str, Any]:
"""Compile an Arduino sketch to verify code correctness""" """Compile an Arduino sketch to verify code correctness"""
try: try:
@ -245,7 +245,7 @@ void loop() {{
sketch_name: str, sketch_name: str,
port: str, port: str,
board_fqbn: str = "" board_fqbn: str = ""
) -> Dict[str, Any]: ) -> dict[str, Any]:
"""Compile and upload sketch to Arduino board""" """Compile and upload sketch to Arduino board"""
try: try:
@ -313,8 +313,8 @@ void loop() {{
self, self,
ctx: Context | None, ctx: Context | None,
sketch_name: str, sketch_name: str,
file_name: Optional[str] = None file_name: str | None = None
) -> Dict[str, Any]: ) -> dict[str, Any]:
"""Read the contents of a sketch file""" """Read the contents of a sketch file"""
try: try:
@ -364,9 +364,9 @@ void loop() {{
ctx: Context | None, ctx: Context | None,
sketch_name: str, sketch_name: str,
content: str, content: str,
file_name: Optional[str] = None, file_name: str | None = None,
auto_compile: bool = True auto_compile: bool = True
) -> Dict[str, Any]: ) -> dict[str, Any]:
"""Write or update a sketch file""" """Write or update a sketch file"""
try: try:
@ -390,7 +390,7 @@ void loop() {{
result = { result = {
"success": True, "success": True,
"message": f"File written successfully", "message": "File written successfully",
"path": str(file_path), "path": str(file_path),
"size": len(content), "size": len(content),
"lines": len(content.splitlines()) "lines": len(content.splitlines())
@ -420,4 +420,4 @@ void loop() {{
elif os.name == 'nt': # Windows elif os.name == 'nt': # Windows
os.startfile(str(file_path)) os.startfile(str(file_path))
except Exception as e: except Exception as e:
log.warning(f"Could not open file automatically: {e}") log.warning(f"Could not open file automatically: {e}")

View File

@ -4,14 +4,12 @@ Provides config management, bootloader operations, and sketch utilities
""" """
import json import json
import os
import shutil
import zipfile
from typing import List, Dict, Optional, Any
from pathlib import Path
import subprocess
import logging import logging
import yaml import os
import subprocess
import zipfile
from pathlib import Path
from typing import Any
from fastmcp import Context from fastmcp import Context
from fastmcp.contrib.mcp_mixin import MCPMixin, mcp_tool from fastmcp.contrib.mcp_mixin import MCPMixin, mcp_tool
@ -30,7 +28,7 @@ class ArduinoSystemAdvanced(MCPMixin):
self.sketch_dir = Path(config.sketch_dir).expanduser() self.sketch_dir = Path(config.sketch_dir).expanduser()
self.config_file = Path.home() / ".arduino15" / "arduino-cli.yaml" self.config_file = Path.home() / ".arduino15" / "arduino-cli.yaml"
async def _run_arduino_cli(self, args: List[str], capture_output: bool = True) -> Dict[str, Any]: async def _run_arduino_cli(self, args: list[str], capture_output: bool = True) -> dict[str, Any]:
"""Run Arduino CLI command and return result""" """Run Arduino CLI command and return result"""
cmd = [self.cli_path] + args cmd = [self.cli_path] + args
@ -73,9 +71,9 @@ class ArduinoSystemAdvanced(MCPMixin):
async def config_init( async def config_init(
self, self,
overwrite: bool = Field(False, description="Overwrite existing configuration"), overwrite: bool = Field(False, description="Overwrite existing configuration"),
additional_urls: Optional[List[str]] = Field(None, description="Additional board package URLs"), additional_urls: list[str] | None = Field(None, description="Additional board package URLs"),
ctx: Context = None ctx: Context = None
) -> Dict[str, Any]: ) -> dict[str, Any]:
"""Initialize Arduino CLI configuration with defaults""" """Initialize Arduino CLI configuration with defaults"""
args = ["config", "init"] args = ["config", "init"]
@ -110,7 +108,7 @@ class ArduinoSystemAdvanced(MCPMixin):
self, self,
key: str = Field(..., description="Configuration key (e.g., 'board_manager.additional_urls')"), key: str = Field(..., description="Configuration key (e.g., 'board_manager.additional_urls')"),
ctx: Context = None ctx: Context = None
) -> Dict[str, Any]: ) -> dict[str, Any]:
"""Get a specific configuration value""" """Get a specific configuration value"""
args = ["config", "get", key] args = ["config", "get", key]
@ -143,7 +141,7 @@ class ArduinoSystemAdvanced(MCPMixin):
key: str = Field(..., description="Configuration key"), key: str = Field(..., description="Configuration key"),
value: Any = Field(..., description="Configuration value"), value: Any = Field(..., description="Configuration value"),
ctx: Context = None ctx: Context = None
) -> Dict[str, Any]: ) -> dict[str, Any]:
"""Set a configuration value""" """Set a configuration value"""
# Convert value to appropriate format # Convert value to appropriate format
if isinstance(value, list): if isinstance(value, list):
@ -174,7 +172,7 @@ class ArduinoSystemAdvanced(MCPMixin):
async def config_dump( async def config_dump(
self, self,
ctx: Context = None ctx: Context = None
) -> Dict[str, Any]: ) -> dict[str, Any]:
"""Get the complete Arduino CLI configuration""" """Get the complete Arduino CLI configuration"""
args = ["config", "dump", "--json"] args = ["config", "dump", "--json"]
@ -217,7 +215,7 @@ class ArduinoSystemAdvanced(MCPMixin):
verify: bool = Field(True, description="Verify after burning"), verify: bool = Field(True, description="Verify after burning"),
verbose: bool = Field(False, description="Verbose output"), verbose: bool = Field(False, description="Verbose output"),
ctx: Context = None ctx: Context = None
) -> Dict[str, Any]: ) -> dict[str, Any]:
""" """
Burn bootloader to a board Burn bootloader to a board
@ -257,11 +255,11 @@ class ArduinoSystemAdvanced(MCPMixin):
async def archive_sketch( async def archive_sketch(
self, self,
sketch_name: str = Field(..., description="Name of the sketch to archive"), sketch_name: str = Field(..., description="Name of the sketch to archive"),
output_path: Optional[str] = Field(None, description="Output path for archive"), output_path: str | None = Field(None, description="Output path for archive"),
include_libraries: bool = Field(False, description="Include used libraries"), include_libraries: bool = Field(False, description="Include used libraries"),
include_build_artifacts: bool = Field(False, description="Include compiled binaries"), include_build_artifacts: bool = Field(False, description="Include compiled binaries"),
ctx: Context = None ctx: Context = None
) -> Dict[str, Any]: ) -> dict[str, Any]:
"""Create a ZIP archive of a sketch for easy sharing""" """Create a ZIP archive of a sketch for easy sharing"""
sketch_path = self.sketch_dir / sketch_name sketch_path = self.sketch_dir / sketch_name
@ -325,10 +323,10 @@ class ArduinoSystemAdvanced(MCPMixin):
self, self,
sketch_name: str = Field(..., description="Name for the new sketch"), sketch_name: str = Field(..., description="Name for the new sketch"),
template: str = Field("default", description="Template type: default, blink, serial, wifi, sensor"), template: str = Field("default", description="Template type: default, blink, serial, wifi, sensor"),
board: Optional[str] = Field(None, description="Board FQBN to attach"), board: str | None = Field(None, description="Board FQBN to attach"),
metadata: Optional[Dict[str, str]] = Field(None, description="Sketch metadata"), metadata: dict[str, str] | None = Field(None, description="Sketch metadata"),
ctx: Context = None ctx: Context = None
) -> Dict[str, Any]: ) -> dict[str, Any]:
"""Create a new sketch from predefined templates""" """Create a new sketch from predefined templates"""
sketch_path = self.sketch_dir / sketch_name sketch_path = self.sketch_dir / sketch_name
@ -500,9 +498,9 @@ void loop() {
self, self,
port: str = Field(..., description="Serial port to monitor"), port: str = Field(..., description="Serial port to monitor"),
baudrate: int = Field(115200, description="Baud rate"), baudrate: int = Field(115200, description="Baud rate"),
config: Optional[Dict[str, Any]] = Field(None, description="Monitor configuration"), config: dict[str, Any] | None = Field(None, description="Monitor configuration"),
ctx: Context = None ctx: Context = None
) -> Dict[str, Any]: ) -> dict[str, Any]:
""" """
Start Arduino CLI's built-in monitor with advanced features Start Arduino CLI's built-in monitor with advanced features
@ -532,4 +530,4 @@ void loop() {
"process": result.get("process") "process": result.get("process")
} }
return result return result

View File

@ -5,9 +5,8 @@ Handles wraparound and cursor invalidation for long-running sessions
import uuid import uuid
from collections import deque from collections import deque
from dataclasses import dataclass, asdict from dataclasses import asdict, dataclass
from datetime import datetime from datetime import datetime
from typing import Dict, List, Optional, Tuple, Any
from enum import Enum from enum import Enum
@ -38,7 +37,7 @@ class CursorState:
cursor_id: str cursor_id: str
position: int # Global index position position: int # Global index position
created_at: str created_at: str
last_read: Optional[str] = None last_read: str | None = None
reads_count: int = 0 reads_count: int = 0
is_valid: bool = True # False if cursor points to overwritten data is_valid: bool = True # False if cursor points to overwritten data
@ -65,7 +64,7 @@ class CircularSerialBuffer:
self.buffer = deque(maxlen=max_size) # Efficient circular buffer self.buffer = deque(maxlen=max_size) # Efficient circular buffer
self.global_index = 0 # Ever-incrementing index self.global_index = 0 # Ever-incrementing index
self.oldest_index = 0 # Index of oldest entry in buffer self.oldest_index = 0 # Index of oldest entry in buffer
self.cursors: Dict[str, CursorState] = {} self.cursors: dict[str, CursorState] = {}
# Statistics # Statistics
self.total_entries = 0 self.total_entries = 0
@ -149,8 +148,8 @@ class CircularSerialBuffer:
self, self,
cursor_id: str, cursor_id: str,
limit: int = 100, limit: int = 100,
port_filter: Optional[str] = None, port_filter: str | None = None,
type_filter: Optional[SerialDataType] = None, type_filter: SerialDataType | None = None,
auto_recover: bool = True auto_recover: bool = True
) -> dict: ) -> dict:
""" """
@ -262,7 +261,7 @@ class CircularSerialBuffer:
return True return True
return False return False
def get_cursor_info(self, cursor_id: str) -> Optional[dict]: def get_cursor_info(self, cursor_id: str) -> dict | None:
"""Get information about a cursor""" """Get information about a cursor"""
if cursor_id not in self.cursors: if cursor_id not in self.cursors:
return None return None
@ -279,11 +278,11 @@ class CircularSerialBuffer:
"entries_ahead": self.buffer[-1].index - cursor.position + 1 if self.buffer and cursor.is_valid else None "entries_ahead": self.buffer[-1].index - cursor.position + 1 if self.buffer and cursor.is_valid else None
} }
def list_cursors(self) -> List[dict]: def list_cursors(self) -> list[dict]:
"""List all active cursors""" """List all active cursors"""
return [self.get_cursor_info(cursor_id) for cursor_id in self.cursors] return [self.get_cursor_info(cursor_id) for cursor_id in self.cursors]
def get_latest(self, port: Optional[str] = None, limit: int = 10) -> List[SerialDataEntry]: def get_latest(self, port: str | None = None, limit: int = 10) -> list[SerialDataEntry]:
"""Get latest entries without cursor""" """Get latest entries without cursor"""
if not self.buffer: if not self.buffer:
return [] return []
@ -295,7 +294,7 @@ class CircularSerialBuffer:
return entries return entries
def clear(self, port: Optional[str] = None): def clear(self, port: str | None = None):
"""Clear buffer for a specific port or all""" """Clear buffer for a specific port or all"""
if port: if port:
# Filter out entries for specified port # Filter out entries for specified port
@ -375,4 +374,4 @@ class CircularSerialBuffer:
"entries_before": old_len, "entries_before": old_len,
"entries_after": len(self.buffer), "entries_after": len(self.buffer),
"entries_dropped": max(0, old_len - len(self.buffer)) "entries_dropped": max(0, old_len - len(self.buffer))
} }

View File

@ -0,0 +1,275 @@
"""
Enhanced Context with Client Capabilities Access
This module provides an enhanced way to access client capabilities through the Context.
It exposes what the client actually declared during initialization, not just what we can detect.
"""
import logging
from typing import Any
from fastmcp import Context
from fastmcp.contrib.mcp_mixin import MCPMixin, mcp_tool
from mcp.types import ToolAnnotations
from pydantic import Field
logger = logging.getLogger(__name__)
class ClientCapabilitiesInfo(MCPMixin):
"""
Enhanced tools to properly access client capabilities from the MCP handshake.
This reveals what the client ACTUALLY declared, not just what we can probe.
"""
def __init__(self, config):
"""Initialize capabilities info component"""
self.config = config
@mcp_tool(
name="client_declared_capabilities",
description="Show what capabilities the client declared during initialization",
annotations=ToolAnnotations(
title="Client Declared Capabilities",
destructiveHint=False,
idempotentHint=True
)
)
async def show_declared_capabilities(
self,
ctx: Context,
verbose: bool = Field(False, description="Show raw capability data")
) -> dict[str, Any]:
"""
Show the actual capabilities the client declared in the initialization handshake.
This is different from probing - this shows what the client SAID it supports.
"""
result = {
"has_session": hasattr(ctx, 'session'),
"client_params_available": False,
"declared_capabilities": {},
"insights": []
}
# Access the underlying session
if not hasattr(ctx, 'session'):
result["error"] = "No session available in context"
return result
session = ctx.session
# Check if we have client_params (from initialization)
if hasattr(session, '_client_params') and session._client_params:
result["client_params_available"] = True
client_params = session._client_params
# Get the capabilities
if hasattr(client_params, 'capabilities'):
caps = client_params.capabilities
# Check sampling capability
if hasattr(caps, 'sampling'):
sampling_cap = caps.sampling
result["declared_capabilities"]["sampling"] = {
"declared": sampling_cap is not None,
"details": str(sampling_cap) if sampling_cap else None
}
else:
result["declared_capabilities"]["sampling"] = {
"declared": False,
"details": "Not declared"
}
# Check roots capability
if hasattr(caps, 'roots'):
roots_cap = caps.roots
if roots_cap:
result["declared_capabilities"]["roots"] = {
"declared": True,
"listChanged": getattr(roots_cap, 'listChanged', False)
}
else:
result["declared_capabilities"]["roots"] = {
"declared": False
}
# Check other capabilities
for attr in ['resources', 'prompts', 'tools']:
if hasattr(caps, attr):
cap = getattr(caps, attr)
if cap:
result["declared_capabilities"][attr] = {
"declared": True,
"listChanged": getattr(cap, 'listChanged', False) if cap else False
}
else:
result["declared_capabilities"][attr] = {
"declared": False
}
# Check experimental capabilities
if hasattr(caps, 'experimental'):
result["declared_capabilities"]["experimental"] = caps.experimental or {}
if verbose:
result["raw_capabilities"] = str(caps)
else:
result["error"] = "No capabilities found in client params"
# Get client info
if hasattr(client_params, 'clientInfo'):
client_info = client_params.clientInfo
if client_info:
result["client_info"] = {
"name": getattr(client_info, 'name', 'unknown'),
"version": getattr(client_info, 'version', 'unknown')
}
else:
result["error"] = "Client params not available - initialization data missing"
result["insights"].append("Client didn't provide initialization parameters")
# Generate insights based on findings
if result["declared_capabilities"]:
# Sampling insight
sampling = result["declared_capabilities"].get("sampling", {})
if not sampling.get("declared"):
result["insights"].append(
"⚠️ Client didn't declare sampling capability - this is why sampling fails!"
)
else:
result["insights"].append(
"✅ Client properly declared sampling support"
)
# Roots insight
roots = result["declared_capabilities"].get("roots", {})
if not roots.get("declared"):
result["insights"].append(
"Client didn't declare roots support (but may still work)"
)
elif roots.get("listChanged"):
result["insights"].append(
"Client supports dynamic roots updates"
)
return result
@mcp_tool(
name="client_capability_check",
description="Test if client declared support for specific capabilities",
annotations=ToolAnnotations(
title="Check Specific Capability",
destructiveHint=False,
idempotentHint=True
)
)
async def check_capability(
self,
ctx: Context,
capability: str = Field(..., description="Capability to check: sampling, roots, resources, prompts, tools")
) -> dict[str, Any]:
"""
Check if the client declared a specific capability.
This uses the same check_client_capability method that FastMCP uses internally.
"""
if not hasattr(ctx, 'session'):
return {
"capability": capability,
"supported": False,
"error": "No session available"
}
session = ctx.session
# Try to use the check_client_capability method directly
if hasattr(session, 'check_client_capability'):
from mcp.types import ClientCapabilities, RootsCapability, SamplingCapability
# Build the capability object to check
check_cap = ClientCapabilities()
if capability == "sampling":
check_cap.sampling = SamplingCapability()
elif capability == "roots":
check_cap.roots = RootsCapability()
# Add other capabilities as needed
try:
supported = session.check_client_capability(check_cap)
return {
"capability": capability,
"supported": supported,
"check_method": "session.check_client_capability",
"explanation": f"Client {'did' if supported else 'did not'} declare {capability} support"
}
except Exception as e:
return {
"capability": capability,
"supported": False,
"error": str(e)
}
else:
return {
"capability": capability,
"supported": False,
"error": "check_client_capability method not available"
}
@mcp_tool(
name="client_fix_capabilities",
description="Suggest fixes for capability issues",
annotations=ToolAnnotations(
title="Capability Issue Fixes",
destructiveHint=False,
idempotentHint=True
)
)
async def suggest_fixes(self, ctx: Context) -> dict[str, Any]:
"""Analyze capability issues and suggest fixes"""
# First get the declared capabilities
caps_result = await self.show_declared_capabilities(ctx, verbose=False)
fixes = []
# Check sampling
sampling = caps_result.get("declared_capabilities", {}).get("sampling", {})
if not sampling.get("declared"):
fixes.append({
"issue": "Sampling not declared by client",
"impact": "AI-powered features (like WireViz from description) will fail",
"fix": "Applied patch to FastMCP context.py to bypass capability check",
"status": "✅ Fixed"
})
# Check roots
roots = caps_result.get("declared_capabilities", {}).get("roots", {})
if roots.get("declared") and not roots.get("listChanged"):
fixes.append({
"issue": "Roots supported but not dynamic updates",
"impact": "Can't notify client when roots change",
"fix": "No fix needed - static roots work fine",
"status": " Informational"
})
# Check for other missing capabilities
for cap in ["resources", "prompts", "tools"]:
cap_info = caps_result.get("declared_capabilities", {}).get(cap, {})
if not cap_info.get("declared"):
fixes.append({
"issue": f"{cap.capitalize()} capability not declared",
"impact": f"Can't use {cap}-related features",
"fix": f"Client needs to declare {cap} support",
"status": "⚠️ Client limitation"
})
return {
"capability_issues": len(fixes),
"fixes": fixes,
"summary": "Sampling issue has been patched. Other limitations are client-side."
}

View File

@ -0,0 +1,467 @@
"""
Client Debug Tool for Arduino MCP Server
This component provides comprehensive debugging information about the connected MCP client,
including capabilities, features, and current state. Essential for troubleshooting and
understanding what features are available.
"""
import logging
from datetime import datetime
from typing import Any
from fastmcp import Context
from fastmcp.contrib.mcp_mixin import MCPMixin, mcp_tool
from mcp.types import ToolAnnotations
from pydantic import Field
logger = logging.getLogger(__name__)
class ClientDebugInfo(MCPMixin):
"""
Debug tool that reveals everything about the connected MCP client.
This component helps developers and users understand:
- What capabilities the client supports
- What features are available
- Current connection state
- Why certain features might not work
"""
def __init__(self, config):
"""Initialize debug component"""
self.config = config
self.client_info = {
"connection_time": datetime.now().isoformat(),
"capabilities_detected": {},
"runtime_features": {},
"test_results": {}
}
def update_capability(self, name: str, value: Any):
"""Update a detected capability"""
self.client_info["capabilities_detected"][name] = value
logger.info(f"Client capability detected: {name} = {value}")
@mcp_tool(
name="client_debug_info",
description="Show comprehensive debug information about the MCP client",
annotations=ToolAnnotations(
title="Client Debug Information",
destructiveHint=False,
idempotentHint=True
)
)
async def debug_info(
self,
ctx: Context,
verbose: bool = Field(False, description="Show detailed technical information")
) -> dict[str, Any]:
"""
Get comprehensive debug information about the connected MCP client.
This tool reveals:
- Client capabilities (roots, tools, prompts, resources)
- Feature support (sampling, notifications)
- Current state (roots, connections)
- Runtime behavior
"""
# ============================================================
# 1. Check Notification Support
# ============================================================
notification_support = {
"tools.listChanged": False,
"resources.listChanged": False,
"prompts.listChanged": False,
"roots.listChanged": False
}
# These would be set during server initialization based on client capabilities
# For now, we'll detect what we can at runtime
# ============================================================
# 2. Check Sampling Support
# ============================================================
sampling_info = await self._check_sampling_support(ctx)
# ============================================================
# 3. Check Roots Support
# ============================================================
roots_info = await self._check_roots_support(ctx)
# ============================================================
# 4. Check Resources Support
# ============================================================
resources_info = await self._check_resources_support(ctx)
# ============================================================
# 5. Check Context Features
# ============================================================
context_features = self._analyze_context(ctx, verbose)
# ============================================================
# 6. Runtime Tests
# ============================================================
runtime_tests = await self._run_runtime_tests(ctx)
# ============================================================
# 7. Build Debug Report
# ============================================================
debug_report = {
"client_info": {
"connection_time": self.client_info["connection_time"],
"session_duration": self._calculate_session_duration()
},
"capabilities": {
"notifications": notification_support,
"sampling": sampling_info,
"roots": roots_info,
"resources": resources_info
},
"context_analysis": context_features,
"runtime_tests": runtime_tests,
"recommendations": self._generate_recommendations(
sampling_info,
roots_info,
notification_support
),
"feature_matrix": self._create_feature_matrix(
sampling_info,
roots_info,
resources_info,
notification_support
)
}
if verbose:
debug_report["technical_details"] = {
"context_type": str(type(ctx)),
"context_dir": [attr for attr in dir(ctx) if not attr.startswith('_')],
"context_methods": {
attr: callable(getattr(ctx, attr))
for attr in dir(ctx)
if not attr.startswith('_')
},
"raw_capabilities": self.client_info.get("capabilities_detected", {})
}
return debug_report
async def _check_sampling_support(self, ctx: Context) -> dict[str, Any]:
"""Check if client supports sampling (AI completion)"""
sampling_info = {
"supported": False,
"method_exists": False,
"callable": False,
"test_result": None,
"error": None
}
# Check if method exists
if hasattr(ctx, 'sample'):
sampling_info["method_exists"] = True
# Check if it's callable
if callable(ctx.sample):
sampling_info["callable"] = True
sampling_info["supported"] = True
# Try to get method signature if possible
try:
import inspect
sig = inspect.signature(ctx.sample)
sampling_info["signature"] = str(sig)
except:
pass
# We could try a test sampling, but that might be expensive
sampling_info["test_result"] = "Available (not tested to avoid cost)"
else:
sampling_info["error"] = "sample attribute exists but is not callable"
else:
sampling_info["error"] = "sample method not found in context"
return sampling_info
async def _check_roots_support(self, ctx: Context) -> dict[str, Any]:
"""Check roots support and current roots"""
roots_info = {
"supported": False,
"method_exists": False,
"current_roots": [],
"root_count": 0,
"error": None
}
# Check if list_roots exists
if hasattr(ctx, 'list_roots'):
roots_info["method_exists"] = True
# Try to get current roots
try:
roots = await ctx.list_roots()
roots_info["supported"] = True
roots_info["root_count"] = len(roots) if roots else 0
# Parse roots information
if roots:
for root in roots:
root_data = {
"name": getattr(root, 'name', 'unknown'),
"uri": getattr(root, 'uri', 'unknown')
}
# Parse URI to get path
if root_data["uri"].startswith("file://"):
root_data["path"] = root_data["uri"].replace("file://", "")
roots_info["current_roots"].append(root_data)
except Exception as e:
roots_info["error"] = f"Could not retrieve roots: {str(e)}"
else:
roots_info["error"] = "list_roots method not found in context"
return roots_info
async def _check_resources_support(self, ctx: Context) -> dict[str, Any]:
"""Check resources support"""
resources_info = {
"supported": False,
"method_exists": False,
"resource_count": 0,
"templates_count": 0,
"error": None
}
# Check for list_resources
if hasattr(ctx, 'list_resources'):
resources_info["method_exists"] = True
try:
# Some servers might expose resources
resources = await ctx.list_resources()
resources_info["supported"] = True
resources_info["resource_count"] = len(resources) if resources else 0
except:
pass
# Check for list_resource_templates
if hasattr(ctx, 'list_resource_templates'):
try:
templates = await ctx.list_resource_templates()
resources_info["templates_count"] = len(templates) if templates else 0
except:
pass
return resources_info
def _analyze_context(self, ctx: Context, verbose: bool) -> dict[str, Any]:
"""Analyze the context object"""
context_info = {
"available_methods": [],
"available_properties": [],
"mcp_specific": []
}
# Categorize context attributes
for attr in dir(ctx):
if attr.startswith('_'):
continue
attr_value = getattr(ctx, attr, None)
if callable(attr_value):
context_info["available_methods"].append(attr)
# Identify MCP-specific methods
if any(keyword in attr for keyword in ['resource', 'prompt', 'tool', 'sample', 'root']):
context_info["mcp_specific"].append(attr)
else:
context_info["available_properties"].append(attr)
return context_info
async def _run_runtime_tests(self, ctx: Context) -> dict[str, Any]:
"""Run runtime tests to verify features"""
tests = {
"roots_retrieval": "not_tested",
"sampling_available": "not_tested",
"context_type": str(type(ctx).__name__)
}
# Test roots retrieval
try:
if hasattr(ctx, 'list_roots'):
roots = await ctx.list_roots()
tests["roots_retrieval"] = "success" if roots is not None else "empty"
except Exception as e:
tests["roots_retrieval"] = f"failed: {str(e)[:50]}"
# Test sampling (without actually calling it to avoid costs)
if hasattr(ctx, 'sample') and callable(ctx.sample):
tests["sampling_available"] = "available"
else:
tests["sampling_available"] = "not_available"
return tests
def _calculate_session_duration(self) -> str:
"""Calculate how long the session has been active"""
try:
start = datetime.fromisoformat(self.client_info["connection_time"])
duration = datetime.now() - start
hours = duration.seconds // 3600
minutes = (duration.seconds % 3600) // 60
seconds = duration.seconds % 60
if hours > 0:
return f"{hours}h {minutes}m {seconds}s"
elif minutes > 0:
return f"{minutes}m {seconds}s"
else:
return f"{seconds}s"
except:
return "unknown"
def _generate_recommendations(
self,
sampling_info: dict,
roots_info: dict,
notification_support: dict
) -> list[str]:
"""Generate recommendations based on client capabilities"""
recommendations = []
# Sampling recommendations
if not sampling_info.get("supported"):
recommendations.append(
"🔸 Sampling not available - AI features will use fallback templates"
)
else:
recommendations.append(
"✅ Sampling available - AI-powered features fully functional"
)
# Roots recommendations
if roots_info.get("root_count", 0) > 0:
recommendations.append(
f"{roots_info['root_count']} root(s) configured - using client workspace"
)
elif roots_info.get("supported"):
recommendations.append(
"🔸 Roots supported but none configured - using default directories"
)
else:
recommendations.append(
"🔸 Roots not supported - using default directories"
)
# Notification recommendations
if not any(notification_support.values()):
recommendations.append(
"🔸 No dynamic notifications - tools remain static during session"
)
return recommendations
def _create_feature_matrix(
self,
sampling_info: dict,
roots_info: dict,
resources_info: dict,
notification_support: dict
) -> dict[str, str]:
"""Create a feature support matrix"""
def status_icon(supported: bool) -> str:
return "" if supported else ""
return {
"AI Completion (sampling)": status_icon(sampling_info.get("supported", False)),
"Workspace Roots": status_icon(roots_info.get("supported", False)),
"Resources": status_icon(resources_info.get("supported", False)),
"Dynamic Tools": status_icon(notification_support.get("tools.listChanged", False)),
"Dynamic Resources": status_icon(notification_support.get("resources.listChanged", False)),
"Dynamic Prompts": status_icon(notification_support.get("prompts.listChanged", False)),
"Dynamic Roots": status_icon(notification_support.get("roots.listChanged", False))
}
@mcp_tool(
name="client_test_sampling",
description="Test if client sampling works with a simple prompt",
annotations=ToolAnnotations(
title="Test Client Sampling",
destructiveHint=False,
idempotentHint=True
)
)
async def test_sampling(
self,
ctx: Context,
test_prompt: str = Field("Say 'Hello MCP' in exactly 3 words", description="Simple test prompt")
) -> dict[str, Any]:
"""Test client sampling with a simple, low-cost prompt"""
if not hasattr(ctx, 'sample'):
return {
"success": False,
"error": "Sampling not available - ctx.sample method not found"
}
if not callable(ctx.sample):
return {
"success": False,
"error": "ctx.sample exists but is not callable"
}
try:
from mcp.types import SamplingMessage, TextContent
messages = [
SamplingMessage(
role="user",
content=TextContent(type="text", text=test_prompt)
)
]
# Try minimal sampling call
result = await ctx.sample(
messages=messages,
max_tokens=20 # Very small to minimize cost
)
return {
"success": True,
"test_prompt": test_prompt,
"response": result.content if result else "No response",
"response_type": type(result).__name__ if result else "None",
"sampling_works": True
}
except Exception as e:
return {
"success": False,
"error": str(e),
"error_type": type(e).__name__,
"sampling_works": False,
"hint": "Check if the client has sampling configured correctly"
}

View File

@ -1,76 +0,0 @@
"""
Example of how to use FastMCP sampling with WireViz manager
This shows the pattern for integrating client-side LLM sampling
into MCP servers, eliminating the need for API keys.
"""
from fastmcp import FastMCP, Context
from .wireviz_manager import WireVizManager
from ..config import ArduinoServerConfig
# Create the FastMCP server
mcp = FastMCP(
name="Arduino & WireViz Server",
description="Arduino development with AI-powered circuit diagrams"
)
@mcp.tool()
async def generate_circuit_from_description(
ctx: Context,
description: str,
sketch_name: str = "",
output_base: str = "circuit"
) -> dict:
"""
Generate a circuit diagram from natural language description.
Uses the client's LLM (e.g., Claude) to convert the description
to WireViz YAML, then generates the diagram.
No API keys needed - the client handles the AI completion!
"""
config = ArduinoServerConfig()
# Pass the context to enable sampling
wireviz = WireVizManager(config, mcp_context=ctx)
result = await wireviz.generate_from_description(
description=description,
sketch_name=sketch_name,
output_base=output_base
)
return result
@mcp.tool()
async def generate_circuit_from_yaml(
yaml_content: str,
output_base: str = "circuit"
) -> dict:
"""
Generate a circuit diagram directly from WireViz YAML.
This doesn't require client sampling - just processes the YAML.
"""
config = ArduinoServerConfig()
# No context needed for direct YAML processing
wireviz = WireVizManager(config)
result = await wireviz.generate_from_yaml(
yaml_content=yaml_content,
output_base=output_base
)
return result
# The key insight:
# - Tools that need AI use the Context parameter to access sampling
# - The client (Claude, etc.) does the LLM work
# - No API keys or external services needed
# - Falls back gracefully if client doesn't support sampling

View File

@ -1,211 +0,0 @@
"""
Example of how to add progress reporting to Arduino operations
This demonstrates the pattern for adding progress and logging to MCP tools.
"""
from fastmcp import Context
from fastmcp.contrib.mcp_mixin import MCPMixin, mcp_tool
from mcp.types import ToolAnnotations
class ProgressExample(MCPMixin):
"""Example component showing progress reporting patterns"""
@mcp_tool(
name="example_compile_with_progress",
description="Example showing compilation with progress updates",
annotations=ToolAnnotations(
title="Compile with Progress",
destructiveHint=False,
idempotentHint=True,
)
)
async def compile_with_progress(
self,
ctx: Context | None,
sketch_name: str
) -> dict:
"""
Example of compilation with detailed progress reporting.
Progress stages:
1. Initialization (0-10%)
2. Parsing sketch (10-30%)
3. Compiling core (30-60%)
4. Linking (60-90%)
5. Complete (90-100%)
"""
# Stage 1: Initialization
if ctx:
await ctx.info(f"🚀 Starting compilation of '{sketch_name}'")
await ctx.report_progress(5, 100)
await ctx.debug("Checking sketch directory...")
# Simulate checking
# ... actual check code ...
if ctx:
await ctx.report_progress(10, 100)
await ctx.debug("Sketch found, parsing...")
# Stage 2: Parsing
if ctx:
await ctx.info("📝 Parsing sketch files...")
await ctx.report_progress(20, 100)
# Simulate parsing
# ... actual parsing code ...
if ctx:
await ctx.report_progress(30, 100)
await ctx.debug("Sketch parsed successfully")
# Stage 3: Compiling
if ctx:
await ctx.info("🔧 Compiling source files...")
await ctx.report_progress(45, 100)
# During compilation, report incremental progress
for i in range(3):
if ctx:
await ctx.debug(f"Compiling module {i+1}/3...")
await ctx.report_progress(45 + (i * 5), 100)
# Stage 4: Linking
if ctx:
await ctx.info("🔗 Linking objects...")
await ctx.report_progress(70, 100)
# Simulate linking
# ... actual linking code ...
if ctx:
await ctx.report_progress(90, 100)
await ctx.debug("Generating binary...")
# Stage 5: Complete
if ctx:
await ctx.report_progress(100, 100)
await ctx.info(f"✅ Compilation complete for '{sketch_name}'")
return {
"success": True,
"message": f"Sketch '{sketch_name}' compiled successfully",
"binary_size": "12,456 bytes",
"memory_usage": "1,234 bytes RAM"
}
@mcp_tool(
name="example_upload_with_progress",
description="Example showing upload with progress updates",
annotations=ToolAnnotations(
title="Upload with Progress",
destructiveHint=False,
idempotentHint=False,
)
)
async def upload_with_progress(
self,
ctx: Context | None,
sketch_name: str,
port: str
) -> dict:
"""
Example of upload with progress based on bytes transferred.
"""
total_bytes = 12456 # Example binary size
if ctx:
await ctx.info(f"📤 Starting upload to {port}")
await ctx.report_progress(0, total_bytes)
# Simulate upload with byte-level progress
bytes_sent = 0
chunk_size = 1024
while bytes_sent < total_bytes:
# Simulate sending a chunk
bytes_sent = min(bytes_sent + chunk_size, total_bytes)
if ctx:
await ctx.report_progress(bytes_sent, total_bytes)
# Log at key milestones
percent = (bytes_sent / total_bytes) * 100
if percent in [25, 50, 75]:
await ctx.debug(f"Upload {percent:.0f}% complete")
if ctx:
await ctx.info("✅ Upload complete, verifying...")
await ctx.debug("Board reset successful")
return {
"success": True,
"message": f"Uploaded '{sketch_name}' to {port}",
"bytes_uploaded": total_bytes,
"upload_speed": "115200 baud"
}
@mcp_tool(
name="example_search_with_indeterminate",
description="Example showing indeterminate progress for searches",
annotations=ToolAnnotations(
title="Search with Indeterminate Progress",
destructiveHint=False,
idempotentHint=True,
)
)
async def search_with_indeterminate(
self,
ctx: Context | None,
query: str
) -> dict:
"""
Example of indeterminate progress (no total) for operations
where we don't know the final count ahead of time.
"""
if ctx:
await ctx.info(f"🔍 Searching for '{query}'...")
# No total specified = indeterminate progress
await ctx.report_progress(0)
results_found = 0
# Simulate finding results over time
for batch in range(3):
# Find some results
batch_results = 5 * (batch + 1)
results_found += batch_results
if ctx:
# Report current count without total
await ctx.report_progress(results_found)
await ctx.debug(f"Found {batch_results} more results...")
if ctx:
await ctx.info(f"✅ Search complete: {results_found} results found")
return {
"success": True,
"query": query,
"count": results_found,
"results": [f"Result {i+1}" for i in range(results_found)]
}
# Key patterns demonstrated:
#
# 1. **Percentage Progress**: report_progress(current, 100) for 0-100%
# 2. **Absolute Progress**: report_progress(bytes_sent, total_bytes)
# 3. **Indeterminate Progress**: report_progress(count) with no total
# 4. **Log Levels**:
# - ctx.debug() for detailed diagnostic info
# - ctx.info() for key milestones and status
# - ctx.warning() for potential issues
# - ctx.error() for failures
# 5. **Progress Granularity**: Update at meaningful intervals, not too frequently
# 6. **User Feedback**: Combine progress with informative log messages

View File

@ -4,18 +4,28 @@ Handles serial port connections, monitoring, and communication
""" """
import asyncio import asyncio
import threading import logging
import time import time
from collections.abc import Callable
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import datetime from datetime import datetime
from enum import Enum from enum import Enum
from pathlib import Path
from typing import AsyncIterator, Dict, List, Optional, Set, Callable, Any
import logging
import serial import serial
import serial.tools.list_ports import serial.tools.list_ports
import serial_asyncio
try:
import serial_asyncio
except ImportError:
# Fall back to serial.aio if serial_asyncio not available
try:
from serial import aio as serial_asyncio
except ImportError:
# Create a dummy module for testing without serial
class DummySerialAsyncio:
async def create_serial_connection(*args, **kwargs):
raise NotImplementedError("pyserial-asyncio not installed")
serial_asyncio = DummySerialAsyncio()
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -35,13 +45,13 @@ class SerialPortInfo:
device: str device: str
description: str description: str
hwid: str hwid: str
vid: Optional[int] = None vid: int | None = None
pid: Optional[int] = None pid: int | None = None
serial_number: Optional[str] = None serial_number: str | None = None
location: Optional[str] = None location: str | None = None
manufacturer: Optional[str] = None manufacturer: str | None = None
product: Optional[str] = None product: str | None = None
interface: Optional[str] = None interface: str | None = None
@classmethod @classmethod
def from_list_ports_info(cls, info) -> "SerialPortInfo": def from_list_ports_info(cls, info) -> "SerialPortInfo":
@ -80,22 +90,22 @@ class SerialConnection:
bytesize: int = 8 bytesize: int = 8
parity: str = 'N' parity: str = 'N'
stopbits: float = 1 stopbits: float = 1
timeout: Optional[float] = None timeout: float | None = None
xonxoff: bool = False xonxoff: bool = False
rtscts: bool = False rtscts: bool = False
dsrdtr: bool = False dsrdtr: bool = False
state: ConnectionState = ConnectionState.DISCONNECTED state: ConnectionState = ConnectionState.DISCONNECTED
reader: Optional[asyncio.StreamReader] = None reader: asyncio.StreamReader | None = None
writer: Optional[asyncio.StreamWriter] = None writer: asyncio.StreamWriter | None = None
serial_obj: Optional[serial.Serial] = None serial_obj: serial.Serial | None = None
info: Optional[SerialPortInfo] = None info: SerialPortInfo | None = None
last_activity: Optional[datetime] = None last_activity: datetime | None = None
error_message: Optional[str] = None error_message: str | None = None
listeners: Set[Callable] = field(default_factory=set) listeners: set[Callable] = field(default_factory=set)
buffer: List[str] = field(default_factory=list) buffer: list[str] = field(default_factory=list)
max_buffer_size: int = 1000 max_buffer_size: int = 1000
async def readline(self) -> Optional[str]: async def readline(self) -> str | None:
"""Read a line from the serial port""" """Read a line from the serial port"""
if self.reader and self.state == ConnectionState.CONNECTED: if self.reader and self.state == ConnectionState.CONNECTED:
try: try:
@ -150,7 +160,7 @@ class SerialConnection:
"""Remove a listener""" """Remove a listener"""
self.listeners.discard(callback) self.listeners.discard(callback)
def get_buffer_content(self, last_n_lines: Optional[int] = None) -> List[str]: def get_buffer_content(self, last_n_lines: int | None = None) -> list[str]:
"""Get buffered content""" """Get buffered content"""
if last_n_lines: if last_n_lines:
return self.buffer[-last_n_lines:] return self.buffer[-last_n_lines:]
@ -165,13 +175,13 @@ class SerialConnectionManager:
"""Manages multiple serial connections with auto-reconnection and monitoring""" """Manages multiple serial connections with auto-reconnection and monitoring"""
def __init__(self): def __init__(self):
self.connections: Dict[str, SerialConnection] = {} self.connections: dict[str, SerialConnection] = {}
self.monitoring_tasks: Dict[str, asyncio.Task] = {} self.monitoring_tasks: dict[str, asyncio.Task] = {}
self.auto_reconnect: bool = True self.auto_reconnect: bool = True
self.reconnect_delay: float = 2.0 self.reconnect_delay: float = 2.0
self._lock = asyncio.Lock() self._lock = asyncio.Lock()
self._running = False self._running = False
self._discovery_task: Optional[asyncio.Task] = None self._discovery_task: asyncio.Task | None = None
async def start(self): async def start(self):
"""Start the connection manager""" """Start the connection manager"""
@ -198,14 +208,14 @@ class SerialConnectionManager:
logger.info("Serial Connection Manager stopped") logger.info("Serial Connection Manager stopped")
async def list_ports(self) -> List[SerialPortInfo]: async def list_ports(self) -> list[SerialPortInfo]:
"""List all available serial ports""" """List all available serial ports"""
ports = [] ports = []
for port_info in serial.tools.list_ports.comports(): for port_info in serial.tools.list_ports.comports():
ports.append(SerialPortInfo.from_list_ports_info(port_info)) ports.append(SerialPortInfo.from_list_ports_info(port_info))
return ports return ports
async def list_arduino_ports(self) -> List[SerialPortInfo]: async def list_arduino_ports(self) -> list[SerialPortInfo]:
"""List serial ports that appear to be Arduino-compatible""" """List serial ports that appear to be Arduino-compatible"""
all_ports = await self.list_ports() all_ports = await self.list_ports()
return [p for p in all_ports if p.is_arduino_compatible()] return [p for p in all_ports if p.is_arduino_compatible()]
@ -217,12 +227,12 @@ class SerialConnectionManager:
bytesize: int = 8, # 5, 6, 7, or 8 bytesize: int = 8, # 5, 6, 7, or 8
parity: str = 'N', # 'N', 'E', 'O', 'M', 'S' parity: str = 'N', # 'N', 'E', 'O', 'M', 'S'
stopbits: float = 1, # 1, 1.5, or 2 stopbits: float = 1, # 1, 1.5, or 2
timeout: Optional[float] = None, timeout: float | None = None,
xonxoff: bool = False, # Software flow control xonxoff: bool = False, # Software flow control
rtscts: bool = False, # Hardware (RTS/CTS) flow control rtscts: bool = False, # Hardware (RTS/CTS) flow control
dsrdtr: bool = False, # Hardware (DSR/DTR) flow control dsrdtr: bool = False, # Hardware (DSR/DTR) flow control
inter_byte_timeout: Optional[float] = None, inter_byte_timeout: float | None = None,
write_timeout: Optional[float] = None, write_timeout: float | None = None,
auto_monitor: bool = True, auto_monitor: bool = True,
exclusive: bool = False exclusive: bool = False
) -> SerialConnection: ) -> SerialConnection:
@ -442,18 +452,18 @@ class SerialConnectionManager:
await asyncio.sleep(2.0) # Check every 2 seconds await asyncio.sleep(2.0) # Check every 2 seconds
def get_connection(self, port: str) -> Optional[SerialConnection]: def get_connection(self, port: str) -> SerialConnection | None:
"""Get a connection by port name""" """Get a connection by port name"""
return self.connections.get(port) return self.connections.get(port)
def get_connected_ports(self) -> List[str]: def get_connected_ports(self) -> list[str]:
"""Get list of connected ports""" """Get list of connected ports"""
return [ return [
port for port, conn in self.connections.items() port for port, conn in self.connections.items()
if conn.state == ConnectionState.CONNECTED if conn.state == ConnectionState.CONNECTED
] ]
async def send_command(self, port: str, command: str, wait_for_response: bool = True, timeout: float = 5.0) -> Optional[str]: async def send_command(self, port: str, command: str, wait_for_response: bool = True, timeout: float = 5.0) -> str | None:
""" """
Send a command to a port and optionally wait for response Send a command to a port and optionally wait for response
@ -527,4 +537,4 @@ class SerialConnectionManager:
"""Mark a port as busy (e.g., during upload)""" """Mark a port as busy (e.g., during upload)"""
conn = self.get_connection(port) conn = self.get_connection(port)
if conn: if conn:
conn.state = ConnectionState.BUSY if busy else ConnectionState.CONNECTED conn.state = ConnectionState.BUSY if busy else ConnectionState.CONNECTED

View File

@ -4,18 +4,16 @@ Provides cursor-based serial data access with context management
""" """
import asyncio import asyncio
import json
import uuid import uuid
from dataclasses import dataclass, asdict from dataclasses import asdict, dataclass
from datetime import datetime from datetime import datetime
from typing import Dict, List, Optional, Any
from enum import Enum from enum import Enum
from fastmcp import Context from fastmcp import Context
from fastmcp.tools import Tool from fastmcp.tools import Tool
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from .serial_manager import SerialConnectionManager, SerialConnection, ConnectionState from .serial_manager import SerialConnectionManager
class SerialDataType(str, Enum): class SerialDataType(str, Enum):
@ -47,9 +45,9 @@ class SerialDataBuffer:
def __init__(self, max_size: int = 10000): def __init__(self, max_size: int = 10000):
self.max_size = max_size self.max_size = max_size
self.buffer: List[SerialDataEntry] = [] self.buffer: list[SerialDataEntry] = []
self.global_index = 0 # Ever-incrementing index self.global_index = 0 # Ever-incrementing index
self.cursors: Dict[str, int] = {} # cursor_id -> position self.cursors: dict[str, int] = {} # cursor_id -> position
def add_entry(self, port: str, data: str, data_type: SerialDataType = SerialDataType.RECEIVED): def add_entry(self, port: str, data: str, data_type: SerialDataType = SerialDataType.RECEIVED):
"""Add a new entry to the buffer""" """Add a new entry to the buffer"""
@ -68,7 +66,7 @@ class SerialDataBuffer:
if len(self.buffer) > self.max_size: if len(self.buffer) > self.max_size:
self.buffer.pop(0) self.buffer.pop(0)
def create_cursor(self, start_index: Optional[int] = None) -> str: def create_cursor(self, start_index: int | None = None) -> str:
"""Create a new cursor for reading data""" """Create a new cursor for reading data"""
cursor_id = str(uuid.uuid4()) cursor_id = str(uuid.uuid4())
@ -87,9 +85,9 @@ class SerialDataBuffer:
self, self,
cursor_id: str, cursor_id: str,
limit: int = 100, limit: int = 100,
port_filter: Optional[str] = None, port_filter: str | None = None,
type_filter: Optional[SerialDataType] = None type_filter: SerialDataType | None = None
) -> tuple[List[SerialDataEntry], bool]: ) -> tuple[list[SerialDataEntry], bool]:
""" """
Read entries from cursor position Read entries from cursor position
@ -133,14 +131,14 @@ class SerialDataBuffer:
"""Delete a cursor""" """Delete a cursor"""
self.cursors.pop(cursor_id, None) self.cursors.pop(cursor_id, None)
def get_latest(self, port: Optional[str] = None, limit: int = 10) -> List[SerialDataEntry]: def get_latest(self, port: str | None = None, limit: int = 10) -> list[SerialDataEntry]:
"""Get latest entries without cursor""" """Get latest entries without cursor"""
entries = self.buffer[-limit:] if not port else [ entries = self.buffer[-limit:] if not port else [
e for e in self.buffer if e.port == port e for e in self.buffer if e.port == port
][-limit:] ][-limit:]
return entries return entries
def clear(self, port: Optional[str] = None): def clear(self, port: str | None = None):
"""Clear buffer for a specific port or all""" """Clear buffer for a specific port or all"""
if port: if port:
self.buffer = [e for e in self.buffer if e.port != port] self.buffer = [e for e in self.buffer if e.port != port]
@ -154,7 +152,7 @@ class SerialMonitorContext:
def __init__(self): def __init__(self):
self.connection_manager = SerialConnectionManager() self.connection_manager = SerialConnectionManager()
self.data_buffer = SerialDataBuffer() self.data_buffer = SerialDataBuffer()
self.active_monitors: Dict[str, asyncio.Task] = {} self.active_monitors: dict[str, asyncio.Task] = {}
self._initialized = False self._initialized = False
async def initialize(self): async def initialize(self):
@ -220,10 +218,10 @@ class SerialSendParams(BaseModel):
class SerialReadParams(BaseModel): class SerialReadParams(BaseModel):
"""Parameters for reading serial data""" """Parameters for reading serial data"""
cursor_id: Optional[str] = Field(None, description="Cursor ID for pagination") cursor_id: str | None = Field(None, description="Cursor ID for pagination")
port: Optional[str] = Field(None, description="Filter by port") port: str | None = Field(None, description="Filter by port")
limit: int = Field(100, description="Maximum entries to return") limit: int = Field(100, description="Maximum entries to return")
type_filter: Optional[SerialDataType] = Field(None, description="Filter by data type") type_filter: SerialDataType | None = Field(None, description="Filter by data type")
create_cursor: bool = Field(False, description="Create new cursor if not provided") create_cursor: bool = Field(False, description="Create new cursor if not provided")
@ -234,7 +232,7 @@ class SerialListPortsParams(BaseModel):
class SerialClearBufferParams(BaseModel): class SerialClearBufferParams(BaseModel):
"""Parameters for clearing serial buffer""" """Parameters for clearing serial buffer"""
port: Optional[str] = Field(None, description="Clear specific port or all if None") port: str | None = Field(None, description="Clear specific port or all if None")
class SerialResetBoardParams(BaseModel): class SerialResetBoardParams(BaseModel):
@ -515,4 +513,4 @@ SERIAL_TOOLS = [
SerialClearBufferTool(), SerialClearBufferTool(),
SerialResetBoardTool(), SerialResetBoardTool(),
SerialMonitorStateTool(), SerialMonitorStateTool(),
] ]

View File

@ -1,16 +1,15 @@
"""WireViz circuit diagram generation component""" """WireViz circuit diagram generation component"""
import base64
import datetime import datetime
import logging import logging
import os import os
import subprocess import subprocess
from pathlib import Path from pathlib import Path
from typing import Dict, Optional, Any from typing import Any
from fastmcp import Context from fastmcp import Context
from fastmcp.contrib.mcp_mixin import MCPMixin, mcp_resource, mcp_tool
from fastmcp.utilities.types import Image from fastmcp.utilities.types import Image
from fastmcp.contrib.mcp_mixin import MCPMixin, mcp_tool, mcp_resource from mcp.types import SamplingMessage, ToolAnnotations
from mcp.types import ToolAnnotations, SamplingMessage
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@ -18,8 +17,8 @@ log = logging.getLogger(__name__)
class WireVizRequest(BaseModel): class WireVizRequest(BaseModel):
"""Request model for WireViz operations""" """Request model for WireViz operations"""
yaml_content: Optional[str] = Field(None, description="WireViz YAML content") yaml_content: str | None = Field(None, description="WireViz YAML content")
description: Optional[str] = Field(None, description="Natural language circuit description") description: str | None = Field(None, description="Natural language circuit description")
sketch_name: str = Field("circuit", description="Name for output files") sketch_name: str = Field("circuit", description="Name for output files")
output_base: str = Field("circuit", description="Base name for output files") output_base: str = Field("circuit", description="Base name for output files")
@ -94,7 +93,7 @@ For AI-powered generation from descriptions, use the
self, self,
yaml_content: str, yaml_content: str,
output_base: str = "circuit" output_base: str = "circuit"
) -> Dict[str, Any]: ) -> dict[str, Any]:
"""Generate circuit diagram from WireViz YAML""" """Generate circuit diagram from WireViz YAML"""
try: try:
# Create timestamped output directory # Create timestamped output directory
@ -118,58 +117,81 @@ For AI-powered generation from descriptions, use the
if result.returncode != 0: if result.returncode != 0:
error_msg = f"WireViz failed: {result.stderr}" error_msg = f"WireViz failed: {result.stderr}"
log.error(error_msg) log.error(error_msg)
return {"error": error_msg} from mcp.types import TextContent
return TextContent(
type="text",
text=f"Error: {error_msg}"
)
# Find generated PNG # Find generated PNG
png_files = list(output_dir.glob("*.png")) png_files = list(output_dir.glob("*.png"))
if not png_files: if not png_files:
return {"error": "No PNG file generated"} from mcp.types import TextContent
return TextContent(
type="text",
text="Error: No PNG file generated"
)
png_path = png_files[0] png_path = png_files[0]
# Read and encode image # Read image data
with open(png_path, "rb") as f: with open(png_path, "rb") as f:
image_data = f.read() image_data = f.read()
encoded_image = base64.b64encode(image_data).decode("utf-8")
# Open image in default viewer # Open image in default viewer
self._open_file(png_path) self._open_file(png_path)
return { # Return the Image directly so FastMCP converts it to ImageContent
"success": True, # Include path information in the image annotations
"message": f"Circuit diagram generated: {png_path}", return Image(
"image": Image(data=encoded_image, format="png"), data=image_data, # Use raw bytes, not encoded
"paths": { format="png",
"yaml": str(yaml_path), annotations={
"png": str(png_path), "description": f"Circuit diagram generated: {png_path}",
"directory": str(output_dir) "paths": {
"yaml": str(yaml_path),
"png": str(png_path),
"directory": str(output_dir)
}
} }
} )
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
return {"error": f"WireViz timed out after {self.config.command_timeout} seconds"} from mcp.types import TextContent
return TextContent(
type="text",
text=f"Error: WireViz timed out after {self.config.command_timeout} seconds"
)
except Exception as e: except Exception as e:
log.exception("WireViz generation failed") log.exception("WireViz generation failed")
return {"error": str(e)} from mcp.types import TextContent
return TextContent(
type="text",
text=f"Error: {str(e)}"
)
@mcp_tool( @mcp_tool(
name="wireviz_generate_from_description", name="wireviz_generate_from_description",
description="Generate circuit diagram from natural language using client's LLM", description="Generate circuit diagram from natural language description",
annotations=ToolAnnotations( annotations=ToolAnnotations(
title="Generate Circuit from Description (AI)", title="Generate Circuit from Description",
destructiveHint=False, destructiveHint=False,
idempotentHint=False, idempotentHint=False,
requiresSampling=True, # Indicates this tool needs sampling support
) )
) )
async def generate_from_description( async def generate_from_description(
self, self,
ctx: Context | None, # Type as optional to support debugging/testing ctx: Context,
description: str, description: str,
sketch_name: str = "", sketch_name: str = "",
output_base: str = "circuit" output_base: str = "circuit"
) -> Dict[str, Any]: ) -> dict[str, Any]:
"""Generate circuit diagram from natural language description using client's LLM """Generate circuit diagram from natural language description
This method intelligently handles both sampling-capable and non-sampling clients:
1. Always tries sampling first for clients that support it
2. Falls back gracefully to template generation when sampling isn't available
3. Provides helpful feedback about which mode was used
Args: Args:
ctx: FastMCP context (automatically injected during requests) ctx: FastMCP context (automatically injected during requests)
@ -178,64 +200,94 @@ For AI-powered generation from descriptions, use the
output_base: Base name for output files output_base: Base name for output files
""" """
# Check if context and sampling are available
if ctx is None:
return {
"error": "No context available - this tool must be called through MCP",
"hint": "Make sure you're using an MCP client that supports sampling"
}
if not hasattr(ctx, 'sample'):
return {
"error": "Client sampling not available",
"hint": "Your MCP client must support sampling for AI-powered generation",
"fallback": "You can still create diagrams by writing WireViz YAML manually"
}
try: try:
# Create prompt for YAML generation # Track which method we use for generation
prompt = self._create_wireviz_prompt(description, sketch_name) generation_method = "unknown"
yaml_content = None
# Use client sampling to generate WireViz YAML # Always try sampling first if the context has the method
from mcp.types import TextContent if hasattr(ctx, 'sample') and callable(ctx.sample):
messages = [ try:
SamplingMessage( log.info("Attempting to use client sampling for WireViz generation")
role="user",
content=TextContent(type="text", text=f"You are an expert at creating WireViz YAML circuit diagrams. Return ONLY valid YAML content, no explanations or markdown code blocks.\n\n{prompt}")
)
]
# Request completion from the client # Create prompt for YAML generation
result = await ctx.sample( prompt = self._create_wireviz_prompt(description, sketch_name)
messages=messages,
max_tokens=2000,
temperature=0.3,
stop_sequences=["```"]
)
if not result or not result.content: # Use client sampling to generate WireViz YAML
return {"error": "No response from client LLM"} from mcp.types import TextContent
messages = [
SamplingMessage(
role="user",
content=TextContent(
type="text",
text=f"You are an expert at creating WireViz YAML circuit diagrams. Return ONLY valid YAML content, no explanations or markdown code blocks.\n\n{prompt}"
)
)
]
yaml_content = result.content # Request completion from the client
result = await ctx.sample(
messages=messages,
max_tokens=2000,
temperature=0.3
)
# Clean up the YAML (remove markdown if present) if result and result.content:
yaml_content = self._clean_yaml_content(yaml_content) yaml_content = self._clean_yaml_content(result.content)
generation_method = "ai-generated"
log.info("Successfully generated WireViz YAML using client sampling")
else:
# Sampling returned empty result
log.info("Client sampling returned empty result, falling back to template")
yaml_content = self._generate_template_yaml(description)
generation_method = "template-with-sampling-context"
# Generate diagram from YAML except Exception as e:
# Sampling failed - this is expected for some clients like Claude Desktop
error_msg = str(e)
if "Method not found" in error_msg:
log.info("Client doesn't support sampling endpoint (expected for Claude Desktop)")
else:
log.warning(f"Sampling failed with unexpected error: {error_msg}")
yaml_content = self._generate_template_yaml(description)
generation_method = "template-fallback"
else:
# Context doesn't have sample method at all
log.info("Context doesn't provide sampling capability, using template")
yaml_content = self._generate_template_yaml(description)
generation_method = "template-no-sampling"
# Generate diagram from YAML (regardless of source)
diagram_result = await self.generate_from_yaml( diagram_result = await self.generate_from_yaml(
yaml_content=yaml_content, yaml_content=yaml_content,
output_base=output_base output_base=output_base
) )
if "error" not in diagram_result: # If we get an Image result, enhance its annotations with generation method
diagram_result["yaml_generated"] = yaml_content if hasattr(diagram_result, 'annotations') and isinstance(diagram_result.annotations, dict):
diagram_result["generated_by"] = "client_llm_sampling" diagram_result.annotations['generation_method'] = generation_method
if generation_method.startswith('template'):
diagram_result.annotations['note'] = (
"This is a template diagram. "
"For AI-generated diagrams, use a client that supports sampling "
"or customize the YAML manually."
)
elif generation_method == 'ai-generated':
diagram_result.annotations['note'] = (
"AI-generated circuit diagram based on your description"
)
return diagram_result return diagram_result
except Exception as e: except Exception as e:
log.exception("Client-based WireViz generation failed") log.exception("WireViz generation failed completely")
return {"error": f"Generation failed: {str(e)}"} from mcp.types import TextContent
return TextContent(
type="text",
text=f"Error: Generation failed: {str(e)}\n\nPlease check the logs for details."
)
def _create_wireviz_prompt(self, description: str, sketch_name: str) -> str: def _create_wireviz_prompt(self, description: str, sketch_name: str) -> str:
"""Create prompt for AI to generate WireViz YAML""" """Create prompt for AI to generate WireViz YAML"""
@ -257,6 +309,301 @@ Return ONLY the YAML content, no explanations."""
return base_prompt.format(description=description) return base_prompt.format(description=description)
def _generate_template_yaml(self, description: str) -> str:
"""Generate an intelligent template YAML based on keywords in the description
This provides better starting points when AI sampling isn't available.
"""
desc_lower = description.lower()
# Detect common components and generate appropriate template
# Check display first (before LED) to catch OLED/LCD properly
if 'display' in desc_lower or 'lcd' in desc_lower or 'oled' in desc_lower:
template = self._generate_display_template(description)
elif 'led' in desc_lower:
template = self._generate_led_template(description)
elif 'motor' in desc_lower or 'servo' in desc_lower:
template = self._generate_motor_template(description)
elif 'sensor' in desc_lower:
template = self._generate_sensor_template(description)
elif 'button' in desc_lower or 'switch' in desc_lower:
template = self._generate_button_template(description)
else:
# Generic template
template = f"""# WireViz Circuit Diagram
# Generated from: {description[:100]}...
# Note: This is a template. Customize it for your specific circuit.
# For AI-generated diagrams, use a client that supports sampling.
connectors:
Arduino:
type: Arduino Uno
subtype: female
pinlabels: [GND, 5V, 3.3V, D2, D3, D4, D5, A0, A1]
notes: Main microcontroller board
Component1:
type: Component
subtype: female
pinlabels: [Pin1, Pin2, Pin3]
notes: Customize this for your component
cables:
Cable1:
wirecount: 3
colors: [BK, RD, BL] # Black, Red, Blue
gauge: 22 AWG
notes: Connection cable
connections:
-
- Arduino: [GND]
- Cable1: [1]
- Component1: [Pin1]
-
- Arduino: [5V]
- Cable1: [2]
- Component1: [Pin2]
-
- Arduino: [D2]
- Cable1: [3]
- Component1: [Pin3]
options:
fontname: arial
bgcolor: white
color_mode: full
"""
return template
def _generate_led_template(self, description: str) -> str:
"""Generate LED circuit template"""
return f"""# WireViz LED Circuit
# Description: {description[:100]}...
# Template for LED circuits - customize as needed
connectors:
Arduino:
type: Arduino Uno
subtype: female
pinlabels: [GND, 5V, D9, D10, D11]
notes: Arduino board with PWM pins
LED_Module:
type: LED with Resistor
subtype: female
pinlabels: [Cathode(-), Anode(+)]
notes: LED with current limiting resistor (220Ω)
cables:
LED_Cable:
wirecount: 2
colors: [BK, RD] # Black (GND), Red (Signal)
gauge: 22 AWG
notes: LED connection cable
connections:
-
- Arduino: [GND]
- LED_Cable: [1]
- LED_Module: [Cathode(-)]
-
- Arduino: [D9]
- LED_Cable: [2]
- LED_Module: [Anode(+)]
options:
fontname: arial
bgcolor: white
color_mode: full
"""
def _generate_motor_template(self, description: str) -> str:
"""Generate motor/servo circuit template"""
return f"""# WireViz Motor/Servo Circuit
# Description: {description[:100]}...
# Template for motor control - customize as needed
connectors:
Arduino:
type: Arduino Uno
subtype: female
pinlabels: [GND, 5V, D9]
notes: Arduino board
Servo:
type: Servo Motor
subtype: female
pinlabels: [GND, VCC, Signal]
notes: Standard servo motor
cables:
Servo_Cable:
wirecount: 3
colors: [BN, RD, OR] # Brown (GND), Red (5V), Orange (Signal)
gauge: 22 AWG
notes: Servo connection cable
connections:
-
- Arduino: [GND]
- Servo_Cable: [1]
- Servo: [GND]
-
- Arduino: [5V]
- Servo_Cable: [2]
- Servo: [VCC]
-
- Arduino: [D9]
- Servo_Cable: [3]
- Servo: [Signal]
options:
fontname: arial
bgcolor: white
color_mode: full
"""
def _generate_sensor_template(self, description: str) -> str:
"""Generate sensor circuit template"""
return f"""# WireViz Sensor Circuit
# Description: {description[:100]}...
# Template for sensor connections - customize as needed
connectors:
Arduino:
type: Arduino Uno
subtype: female
pinlabels: [GND, 5V, A0, A1]
notes: Arduino board with analog inputs
Sensor:
type: Sensor Module
subtype: female
pinlabels: [GND, VCC, Signal, NC]
notes: Generic sensor module
cables:
Sensor_Cable:
wirecount: 3
colors: [BK, RD, YE] # Black (GND), Red (5V), Yellow (Signal)
gauge: 22 AWG
notes: Sensor connection cable
connections:
-
- Arduino: [GND]
- Sensor_Cable: [1]
- Sensor: [GND]
-
- Arduino: [5V]
- Sensor_Cable: [2]
- Sensor: [VCC]
-
- Arduino: [A0]
- Sensor_Cable: [3]
- Sensor: [Signal]
options:
fontname: arial
bgcolor: white
color_mode: full
"""
def _generate_button_template(self, description: str) -> str:
"""Generate button/switch circuit template"""
return f"""# WireViz Button/Switch Circuit
# Description: {description[:100]}...
# Template for button input - customize as needed
connectors:
Arduino:
type: Arduino Uno
subtype: female
pinlabels: [GND, 5V, D2]
notes: Arduino board with digital input
Button:
type: Push Button
subtype: female
pinlabels: [Terminal1, Terminal2]
notes: Momentary push button with pull-up resistor
cables:
Button_Cable:
wirecount: 2
colors: [BK, GN] # Black (GND), Green (Signal)
gauge: 22 AWG
notes: Button connection cable
connections:
-
- Arduino: [GND]
- Button_Cable: [1]
- Button: [Terminal1]
-
- Arduino: [D2]
- Button_Cable: [2]
- Button: [Terminal2]
options:
fontname: arial
bgcolor: white
color_mode: full
notes: Pull-up resistor (10) connects D2 to 5V
"""
def _generate_display_template(self, description: str) -> str:
"""Generate display circuit template"""
return f"""# WireViz Display Circuit
# Description: {description[:100]}...
# Template for display connections - customize as needed
connectors:
Arduino:
type: Arduino Uno
subtype: female
pinlabels: [GND, 5V, A4/SDA, A5/SCL]
notes: Arduino with I2C pins
Display:
type: I2C Display
subtype: female
pinlabels: [GND, VCC, SDA, SCL]
notes: I2C OLED/LCD Display
cables:
I2C_Cable:
wirecount: 4
colors: [BK, RD, BL, YE] # Black (GND), Red (5V), Blue (SDA), Yellow (SCL)
gauge: 22 AWG
notes: I2C connection cable
connections:
-
- Arduino: [GND]
- I2C_Cable: [1]
- Display: [GND]
-
- Arduino: [5V]
- I2C_Cable: [2]
- Display: [VCC]
-
- Arduino: [A4/SDA]
- I2C_Cable: [3]
- Display: [SDA]
-
- Arduino: [A5/SCL]
- I2C_Cable: [4]
- Display: [SCL]
options:
fontname: arial
bgcolor: white
color_mode: full
notes: I2C communication at 0x3C or 0x27 address
"""
def _clean_yaml_content(self, content: str) -> str: def _clean_yaml_content(self, content: str) -> str:
"""Remove markdown code blocks if present""" """Remove markdown code blocks if present"""
lines = content.strip().split('\n') lines = content.strip().split('\n')
@ -285,4 +632,4 @@ Return ONLY the YAML content, no explanations."""
elif os.name == 'nt': # Windows elif os.name == 'nt': # Windows
subprocess.run(['cmd', '/c', 'start', '', str(file_path)], check=False, shell=True) subprocess.run(['cmd', '/c', 'start', '', str(file_path)], check=False, shell=True)
except Exception as e: except Exception as e:
log.warning(f"Could not open file automatically: {e}") log.warning(f"Could not open file automatically: {e}")

View File

@ -1,244 +0,0 @@
"""WireViz circuit diagram generation component"""
import base64
import datetime
import logging
import os
import subprocess
import tempfile
from pathlib import Path
from typing import Dict, Optional, Any
from fastmcp.utilities.types import Image
from pydantic import BaseModel, Field
log = logging.getLogger(__name__)
class WireVizRequest(BaseModel):
"""Request model for WireViz operations"""
yaml_content: Optional[str] = Field(None, description="WireViz YAML content")
description: Optional[str] = Field(None, description="Natural language circuit description")
sketch_name: str = Field("circuit", description="Name for output files")
output_base: str = Field("circuit", description="Base name for output files")
class WireVizManager:
"""Manages WireViz circuit diagram generation"""
def __init__(self, config, mcp_context=None):
self.config = config
self.wireviz_path = config.wireviz_path
self.mcp_context = mcp_context # For accessing sampling
def get_instructions(self) -> str:
"""Get WireViz usage instructions"""
return """
# WireViz Circuit Diagram Instructions
WireViz is a tool for generating circuit wiring diagrams from YAML descriptions.
## Basic YAML Structure:
```yaml
connectors:
Arduino:
type: Arduino Uno
pins: [GND, 5V, D2, D3, A0]
LED:
type: LED
pins: [cathode, anode]
cables:
power:
colors: [BK, RD] # Black, Red
gauge: 22 AWG
connections:
- Arduino: [GND]
cable: [1]
LED: [cathode]
- Arduino: [D2]
cable: [2]
LED: [anode]
```
## Color Codes:
- BK: Black, RD: Red, BL: Blue, GN: Green, YE: Yellow
- OR: Orange, VT: Violet, GY: Gray, WH: White, BN: Brown
## Tips:
1. Define all connectors first
2. Specify cable properties (colors, gauge)
3. Map connections clearly
4. Use descriptive names
For AI-powered generation from descriptions, use the
`generate_circuit_diagram_from_description` tool.
"""
async def generate_from_yaml(self, yaml_content: str, output_base: str = "circuit") -> Dict[str, Any]:
"""Generate circuit diagram from WireViz YAML"""
try:
# Create timestamped output directory
timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
output_dir = self.config.sketches_base_dir / f"wireviz_{timestamp}"
output_dir.mkdir(parents=True, exist_ok=True)
# Write YAML to temporary file
yaml_path = output_dir / f"{output_base}.yaml"
yaml_path.write_text(yaml_content)
# Run WireViz
cmd = [self.wireviz_path, str(yaml_path), "-o", str(output_dir)]
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=self.config.command_timeout
)
if result.returncode != 0:
error_msg = f"WireViz failed: {result.stderr}"
log.error(error_msg)
return {"error": error_msg}
# Find generated PNG
png_files = list(output_dir.glob("*.png"))
if not png_files:
return {"error": "No PNG file generated"}
png_path = png_files[0]
# Read and encode image
with open(png_path, "rb") as f:
image_data = f.read()
encoded_image = base64.b64encode(image_data).decode("utf-8")
# Open image in default viewer
self._open_file(png_path)
return {
"success": True,
"message": f"Circuit diagram generated: {png_path}",
"image": Image(data=encoded_image, format="png"),
"paths": {
"yaml": str(yaml_path),
"png": str(png_path),
"directory": str(output_dir)
}
}
except subprocess.TimeoutExpired:
return {"error": f"WireViz timed out after {self.config.command_timeout} seconds"}
except Exception as e:
log.exception("WireViz generation failed")
return {"error": str(e)}
async def generate_from_description(
self,
description: str,
sketch_name: str = "",
output_base: str = "circuit"
) -> Dict[str, Any]:
"""Generate circuit diagram from natural language description using client's LLM"""
if not self.mcp_context:
return {
"error": "MCP context not available. Client sampling is required for AI generation.",
"hint": "The MCP client must support sampling for this feature to work."
}
try:
# Create prompt for YAML generation
prompt = self._create_wireviz_prompt(description, sketch_name)
# Use FastMCP sampling to request completion from the client
from mcp.types import SamplingMessage
messages = [
SamplingMessage(
role="system",
content="You are an expert at creating WireViz YAML circuit diagrams. Return ONLY the YAML content, no explanations or markdown."
),
SamplingMessage(
role="user",
content=prompt
)
]
# Request completion from the client
result = await self.mcp_context.sample(
messages=messages,
max_tokens=2000,
temperature=0.3,
stop_sequences=["```"]
)
if not result or not result.content:
return {"error": "No response from client LLM"}
yaml_content = result.content
# Clean up the YAML (remove markdown if present)
yaml_content = self._clean_yaml_content(yaml_content)
# Generate diagram from YAML
diagram_result = await self.generate_from_yaml(yaml_content, output_base)
if "error" not in diagram_result:
diagram_result["yaml_generated"] = yaml_content
diagram_result["generated_by"] = "client_llm"
return diagram_result
except ImportError:
return {
"error": "Client sampling not available. Your MCP client may not support this feature.",
"fallback": "You can still create diagrams by writing WireViz YAML manually."
}
except Exception as e:
log.exception("Client-based WireViz generation failed")
return {"error": f"Generation failed: {str(e)}"}
def _create_wireviz_prompt(self, description: str, sketch_name: str) -> str:
"""Create prompt for AI to generate WireViz YAML"""
base_prompt = """Generate a WireViz YAML circuit diagram for the following description:
Description: {description}
Requirements:
1. Use proper WireViz YAML syntax
2. Include all necessary connectors, cables, and connections
3. Use appropriate wire colors and gauges
4. Add descriptive labels
5. Follow electrical safety standards
Return ONLY the YAML content, no explanations."""
if sketch_name:
base_prompt += f"\n\nThis is for an Arduino sketch named: {sketch_name}"
return base_prompt.format(description=description)
def _clean_yaml_content(self, content: str) -> str:
"""Remove markdown code blocks if present"""
lines = content.strip().split('\n')
# Remove markdown code fence if present
if lines[0].startswith('```'):
lines = lines[1:]
if lines[-1].startswith('```'):
lines = lines[:-1]
return '\n'.join(lines)
def _open_file(self, file_path: Path) -> None:
"""Open file in default system application"""
try:
if os.name == 'posix': # macOS and Linux
subprocess.run(['open' if os.uname().sysname == 'Darwin' else 'xdg-open', str(file_path)])
elif os.name == 'nt': # Windows
os.startfile(str(file_path))
except Exception as e:
log.warning(f"Could not open file automatically: {e}")

View File

@ -1,7 +1,6 @@
"""Configuration module for MCP Arduino Server""" """Configuration module for MCP Arduino Server"""
import os
from pathlib import Path from pathlib import Path
from typing import Optional, Set
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@ -57,7 +56,7 @@ class ArduinoServerConfig(BaseModel):
) )
# Security settings # Security settings
allowed_file_extensions: Set[str] = Field( allowed_file_extensions: set[str] = Field(
default={".ino", ".cpp", ".c", ".h", ".hpp", ".yaml", ".yml", ".txt", ".md"}, default={".ino", ".cpp", ".c", ".h", ".hpp", ".yaml", ".yml", ".txt", ".md"},
description="Allowed file extensions for operations" description="Allowed file extensions for operations"
) )
@ -92,4 +91,4 @@ class ArduinoServerConfig(BaseModel):
self.arduino_data_dir, self.arduino_data_dir,
self.arduino_user_dir, self.arduino_user_dir,
]: ]:
dir_path.mkdir(parents=True, exist_ok=True) dir_path.mkdir(parents=True, exist_ok=True)

View File

@ -1,218 +0,0 @@
"""Enhanced configuration with MCP roots support for Arduino MCP Server"""
import os
from pathlib import Path
from typing import Optional, Set, List, Dict, Any
from pydantic import BaseModel, Field
import logging
logger = logging.getLogger(__name__)
class ArduinoServerConfigWithRoots(BaseModel):
"""Enhanced configuration that integrates with MCP client roots"""
# Arduino CLI settings
arduino_cli_path: str = Field(
default="arduino-cli",
description="Path to arduino-cli executable"
)
# Fallback directories (used when no roots are provided)
default_sketches_dir: Path = Field(
default_factory=lambda: Path.home() / "Documents" / "Arduino_MCP_Sketches",
description="Default directory for Arduino sketches when no roots provided"
)
arduino_data_dir: Path = Field(
default_factory=lambda: Path.home() / ".arduino15",
description="Arduino data directory"
)
arduino_user_dir: Path = Field(
default_factory=lambda: Path.home() / "Documents" / "Arduino",
description="Arduino user directory"
)
default_fqbn: str = Field(
default="arduino:avr:uno",
description="Default Fully Qualified Board Name"
)
# WireViz settings
wireviz_path: str = Field(
default="wireviz",
description="Path to WireViz executable"
)
# Client sampling settings
enable_client_sampling: bool = Field(
default=True,
description="Enable client-side LLM sampling for AI features"
)
# Security settings
allowed_file_extensions: Set[str] = Field(
default={".ino", ".cpp", ".c", ".h", ".hpp", ".yaml", ".yml", ".txt", ".md"},
description="Allowed file extensions for operations"
)
max_file_size: int = Field(
default=1024 * 1024, # 1MB
description="Maximum file size in bytes"
)
# Performance settings
command_timeout: float = Field(
default=30.0,
description="Command execution timeout in seconds"
)
fuzzy_search_threshold: int = Field(
default=75,
description="Fuzzy search similarity threshold (0-100)"
)
# Logging
log_level: str = Field(
default="INFO",
description="Logging level"
)
# MCP Roots support
use_mcp_roots: bool = Field(
default=True,
description="Use MCP client-provided roots when available"
)
preferred_root_name: Optional[str] = Field(
default=None,
description="Preferred root name to use for sketches (e.g., 'arduino', 'projects')"
)
# Runtime properties (set when roots are available)
_active_roots: Optional[List[Dict[str, Any]]] = None
_selected_sketch_dir: Optional[Path] = None
def select_sketch_directory(self, roots: Optional[List[Dict[str, Any]]] = None) -> Path:
"""
Select the best directory for Arduino sketches based on MCP roots.
Priority order:
1. Root with name matching preferred_root_name
2. Root with 'arduino' in the name (case-insensitive)
3. Root with 'project' in the name (case-insensitive)
4. First available root
5. Default fallback directory
"""
self._active_roots = roots
if not self.use_mcp_roots or not roots:
logger.info(f"No MCP roots available, using default: {self.default_sketches_dir}")
self._selected_sketch_dir = self.default_sketches_dir
return self.default_sketches_dir
# Convert roots to Path objects
root_paths = []
for root in roots:
try:
root_path = {
'name': root.get('name', 'unnamed'),
'path': Path(root['uri'].replace('file://', '')),
'original': root
}
root_paths.append(root_path)
logger.info(f"Found MCP root: {root_path['name']} -> {root_path['path']}")
except Exception as e:
logger.warning(f"Failed to parse root {root}: {e}")
if not root_paths:
logger.info("No valid MCP roots, using default directory")
self._selected_sketch_dir = self.default_sketches_dir
return self.default_sketches_dir
selected = None
# 1. Check for preferred root name
if self.preferred_root_name:
for rp in root_paths:
if rp['name'].lower() == self.preferred_root_name.lower():
selected = rp
logger.info(f"Selected preferred root: {rp['name']}")
break
# 2. Look for 'arduino' in name
if not selected:
for rp in root_paths:
if 'arduino' in rp['name'].lower() or 'arduino' in str(rp['path']).lower():
selected = rp
logger.info(f"Selected Arduino-related root: {rp['name']}")
break
# 3. Look for 'project' in name
if not selected:
for rp in root_paths:
if 'project' in rp['name'].lower() or 'project' in str(rp['path']).lower():
selected = rp
logger.info(f"Selected project-related root: {rp['name']}")
break
# 4. Use first available root
if not selected and root_paths:
selected = root_paths[0]
logger.info(f"Selected first available root: {selected['name']}")
if selected:
# Create Arduino subdirectory within the root
sketch_dir = selected['path'] / 'Arduino_Sketches'
sketch_dir.mkdir(parents=True, exist_ok=True)
self._selected_sketch_dir = sketch_dir
logger.info(f"Using sketch directory: {sketch_dir}")
return sketch_dir
# 5. Fallback to default
logger.info(f"Falling back to default directory: {self.default_sketches_dir}")
self._selected_sketch_dir = self.default_sketches_dir
return self.default_sketches_dir
@property
def sketches_base_dir(self) -> Path:
"""Get the active sketches directory (roots-aware)"""
if self._selected_sketch_dir:
return self._selected_sketch_dir
return self.default_sketches_dir
@property
def sketch_dir(self) -> Path:
"""Alias for compatibility"""
return self.sketches_base_dir
@property
def build_temp_dir(self) -> Path:
"""Build temp directory (derived from sketches_base_dir)"""
return self.sketches_base_dir / "_build_temp"
def ensure_directories(self) -> None:
"""Create necessary directories if they don't exist"""
for dir_path in [
self.sketches_base_dir,
self.build_temp_dir,
self.arduino_data_dir,
self.arduino_user_dir,
]:
dir_path.mkdir(parents=True, exist_ok=True)
def get_roots_info(self) -> str:
"""Get information about active MCP roots"""
if not self._active_roots:
return "No MCP roots configured"
info = ["Active MCP Roots:"]
for root in self._active_roots:
name = root.get('name', 'unnamed')
uri = root.get('uri', 'unknown')
info.append(f" - {name}: {uri}")
if self._selected_sketch_dir:
info.append(f"\nSelected sketch directory: {self._selected_sketch_dir}")
return "\n".join(info)

File diff suppressed because it is too large Load Diff

View File

@ -1,339 +0,0 @@
"""
Enhanced Arduino Server with automatic MCP Roots detection
Automatically uses client-provided roots when available, falls back to env vars.
"""
import logging
import os
from pathlib import Path
from typing import Optional, List, Dict, Any
from fastmcp import FastMCP, Context
from .config import ArduinoServerConfig
from .components import (
ArduinoBoard,
ArduinoDebug,
ArduinoLibrary,
ArduinoSketch,
WireViz,
)
from .components.arduino_serial import ArduinoSerial
from .components.arduino_libraries_advanced import ArduinoLibrariesAdvanced
from .components.arduino_boards_advanced import ArduinoBoardsAdvanced
from .components.arduino_compile_advanced import ArduinoCompileAdvanced
from .components.arduino_system_advanced import ArduinoSystemAdvanced
# Configure logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
class RootsAwareConfig:
"""Wrapper that enhances config with roots support"""
def __init__(self, base_config: ArduinoServerConfig):
self.base_config = base_config
self._roots: Optional[List[Dict[str, Any]]] = None
self._selected_root_path: Optional[Path] = None
self._initialized = False
async def initialize_with_context(self, ctx: Context) -> bool:
"""Initialize with MCP context to get roots"""
try:
# Try to get roots from context
self._roots = await ctx.list_roots()
if self._roots:
logger.info(f"Found {len(self._roots)} MCP roots")
# Select best root for Arduino sketches
selected_path = self._select_best_root()
if selected_path:
self._selected_root_path = selected_path
logger.info(f"Using MCP root for sketches: {selected_path}")
self._initialized = True
return True
else:
logger.info("No MCP roots available, using environment/default config")
except Exception as e:
logger.debug(f"Could not get MCP roots (client may not support them): {e}")
self._initialized = True
return False
def _select_best_root(self) -> Optional[Path]:
"""Select the best root for Arduino sketches"""
if not self._roots:
return None
# Priority order for root selection
for root in self._roots:
try:
root_name = root.get('name', '').lower()
root_uri = root.get('uri', '')
# Skip non-file URIs
if not root_uri.startswith('file://'):
continue
root_path = Path(root_uri.replace('file://', ''))
# Priority 1: Root named 'arduino' or containing 'arduino'
if 'arduino' in root_name:
logger.info(f"Selected Arduino-specific root: {root_name}")
return root_path / 'sketches'
# Priority 2: Root named 'projects' or 'code'
if any(term in root_name for term in ['project', 'code', 'dev']):
logger.info(f"Selected development root: {root_name}")
return root_path / 'Arduino_Sketches'
except Exception as e:
logger.warning(f"Error processing root {root}: {e}")
continue
# Use first available root as fallback
if self._roots:
first_root = self._roots[0]
root_uri = first_root.get('uri', '')
if root_uri.startswith('file://'):
root_path = Path(root_uri.replace('file://', ''))
logger.info(f"Using first available root: {first_root.get('name')}")
return root_path / 'Arduino_Sketches'
return None
@property
def sketches_base_dir(self) -> Path:
"""Get sketches directory (roots-aware)"""
# Use MCP root if available and initialized
if self._initialized and self._selected_root_path:
return self._selected_root_path
# Check environment variable override
env_sketch_dir = os.getenv('MCP_SKETCH_DIR')
if env_sketch_dir:
return Path(env_sketch_dir).expanduser()
# Fall back to base config default
return self.base_config.sketches_base_dir
@property
def sketch_dir(self) -> Path:
"""Alias for compatibility"""
return self.sketches_base_dir
@property
def build_temp_dir(self) -> Path:
"""Build temp directory"""
return self.sketches_base_dir / "_build_temp"
def ensure_directories(self) -> None:
"""Ensure all directories exist"""
self.sketches_base_dir.mkdir(parents=True, exist_ok=True)
self.build_temp_dir.mkdir(parents=True, exist_ok=True)
self.base_config.arduino_data_dir.mkdir(parents=True, exist_ok=True)
self.base_config.arduino_user_dir.mkdir(parents=True, exist_ok=True)
def get_roots_info(self) -> str:
"""Get information about roots configuration"""
info = []
if self._roots:
info.append(f"MCP Roots Available: {len(self._roots)}")
for root in self._roots:
name = root.get('name', 'unnamed')
uri = root.get('uri', 'unknown')
info.append(f" - {name}: {uri}")
else:
info.append("MCP Roots: Not available")
info.append(f"Active Sketch Dir: {self.sketches_base_dir}")
# Show if using env var
if os.getenv('MCP_SKETCH_DIR'):
info.append(f" (from MCP_SKETCH_DIR env var)")
elif self._selected_root_path:
info.append(f" (from MCP root)")
else:
info.append(f" (default)")
return "\n".join(info)
# Delegate all other attributes to base config
def __getattr__(self, name):
return getattr(self.base_config, name)
def create_enhanced_server(base_config: Optional[ArduinoServerConfig] = None) -> FastMCP:
"""
Create Arduino server with automatic roots detection and env var support.
"""
if base_config is None:
base_config = ArduinoServerConfig()
# Wrap config with roots awareness
config = RootsAwareConfig(base_config)
# Ensure base directories exist (may be overridden by roots later)
config.ensure_directories()
# Get package version
try:
from importlib.metadata import version
package_version = version("mcp-arduino-server")
except:
package_version = "2025.09.27"
# Create the FastMCP server
mcp = FastMCP(
name="Arduino Development Server"
)
# Hook to initialize with roots when first tool is called
roots_initialized = False
async def ensure_roots_initialized(ctx: Context):
"""Ensure roots are initialized before any operation"""
nonlocal roots_initialized
if not roots_initialized:
await config.initialize_with_context(ctx)
config.ensure_directories()
roots_initialized = True
logger.info(f"Initialized with sketch directory: {config.sketches_base_dir}")
# Wrap original sketch component to add roots initialization
original_sketch = ArduinoSketch(config)
# Create wrapper for sketch creation that ensures roots
original_create = original_sketch.create_sketch
@mcp.tool(name="arduino_create_sketch")
async def create_sketch(ctx: Context, sketch_name: str) -> Dict[str, Any]:
"""Create a new Arduino sketch (roots-aware)"""
await ensure_roots_initialized(ctx)
# Now config has been initialized with roots if available
return await original_create(sketch_name=sketch_name, ctx=ctx)
# Similarly wrap other sketch operations
original_list = original_sketch.list_sketches
@mcp.tool(name="arduino_list_sketches")
async def list_sketches(ctx: Context) -> Dict[str, Any]:
"""List all Arduino sketches (roots-aware)"""
await ensure_roots_initialized(ctx)
return await original_list(ctx=ctx)
# Initialize all components with wrapped config
library = ArduinoLibrary(config)
board = ArduinoBoard(config)
debug = ArduinoDebug(config)
wireviz = WireViz(config)
serial = ArduinoSerial(config)
# Initialize advanced components
library_advanced = ArduinoLibrariesAdvanced(config)
board_advanced = ArduinoBoardsAdvanced(config)
compile_advanced = ArduinoCompileAdvanced(config)
system_advanced = ArduinoSystemAdvanced(config)
# Register components (sketch is partially registered above)
# Register remaining sketch tools
for tool_name, tool_func in original_sketch._get_tools():
if tool_name not in ["arduino_create_sketch", "arduino_list_sketches"]:
mcp.add_tool(tool_func)
library.register_all(mcp)
board.register_all(mcp)
debug.register_all(mcp)
wireviz.register_all(mcp)
serial.register_all(mcp)
# Register advanced components
library_advanced.register_all(mcp)
board_advanced.register_all(mcp)
compile_advanced.register_all(mcp)
system_advanced.register_all(mcp)
# Add tool to show roots configuration
@mcp.tool(name="arduino_show_directories")
async def show_directories(ctx: Context) -> Dict[str, Any]:
"""Show current directory configuration including MCP roots status"""
await ensure_roots_initialized(ctx)
return {
"sketch_directory": str(config.sketches_base_dir),
"build_directory": str(config.build_temp_dir),
"arduino_data": str(config.arduino_data_dir),
"arduino_user": str(config.arduino_user_dir),
"roots_info": config.get_roots_info(),
"env_vars": {
"MCP_SKETCH_DIR": os.getenv("MCP_SKETCH_DIR", "not set"),
"ARDUINO_CLI_PATH": os.getenv("ARDUINO_CLI_PATH", "not set"),
}
}
# Add server info resource
@mcp.resource(uri="server://info")
async def get_server_info() -> str:
"""Get server configuration info"""
roots_status = "Will be detected on first use"
if roots_initialized:
roots_status = config.get_roots_info()
return f"""
# Arduino Development Server v{package_version}
## With Automatic Roots Detection
## Directory Configuration:
{roots_status}
## Environment Variables:
- MCP_SKETCH_DIR: {os.getenv('MCP_SKETCH_DIR', 'not set')}
- ARDUINO_CLI_PATH: {config.arduino_cli_path}
- ARDUINO_SERIAL_BUFFER_SIZE: {os.getenv('ARDUINO_SERIAL_BUFFER_SIZE', '10000')}
## Features:
- **Automatic Roots Detection**: Uses client-provided roots when available
- **Environment Override**: MCP_SKETCH_DIR overrides roots
- **Fallback Support**: Uses defaults if no roots or env vars
- **60+ Tools**: Complete Arduino development toolkit
- **Memory-Safe Serial**: Circular buffer prevents crashes
- **Professional Debugging**: GDB integration with breakpoints
## How Directory Selection Works:
1. Check for MCP client roots (automatic)
2. Check MCP_SKETCH_DIR environment variable
3. Use default ~/Documents/Arduino_MCP_Sketches
Call 'arduino_show_directories' to see current configuration.
"""
logger.info("Arduino Development Server initialized with automatic roots detection")
return mcp
# Main entry point
def main():
"""Main entry point"""
# Load config from environment
config = ArduinoServerConfig()
# Override from environment if set
if env_sketch_dir := os.getenv("MCP_SKETCH_DIR"):
config.sketches_base_dir = Path(env_sketch_dir).expanduser()
if env_cli_path := os.getenv("ARDUINO_CLI_PATH"):
config.arduino_cli_path = env_cli_path
# Create enhanced server
server = create_enhanced_server(config)
logger.info("Starting Arduino server with automatic roots detection and env var support")
server.run()
if __name__ == "__main__":
main()

View File

@ -8,10 +8,10 @@ Now with automatic MCP roots detection!
import logging import logging
import os import os
from pathlib import Path from pathlib import Path
from typing import Optional, List, Dict, Any from typing import Any
from fastmcp import Context, FastMCP
from fastmcp import FastMCP, Context
from .config import ArduinoServerConfig
from .components import ( from .components import (
ArduinoBoard, ArduinoBoard,
ArduinoDebug, ArduinoDebug,
@ -19,11 +19,12 @@ from .components import (
ArduinoSketch, ArduinoSketch,
WireViz, WireViz,
) )
from .components.arduino_serial import ArduinoSerial
from .components.arduino_libraries_advanced import ArduinoLibrariesAdvanced
from .components.arduino_boards_advanced import ArduinoBoardsAdvanced from .components.arduino_boards_advanced import ArduinoBoardsAdvanced
from .components.arduino_compile_advanced import ArduinoCompileAdvanced from .components.arduino_compile_advanced import ArduinoCompileAdvanced
from .components.arduino_libraries_advanced import ArduinoLibrariesAdvanced
from .components.arduino_serial import ArduinoSerial
from .components.arduino_system_advanced import ArduinoSystemAdvanced from .components.arduino_system_advanced import ArduinoSystemAdvanced
from .config import ArduinoServerConfig
# Configure logging # Configure logging
logging.basicConfig(level=logging.INFO) logging.basicConfig(level=logging.INFO)
@ -35,8 +36,8 @@ class RootsAwareConfig:
def __init__(self, base_config: ArduinoServerConfig): def __init__(self, base_config: ArduinoServerConfig):
self.base_config = base_config self.base_config = base_config
self._roots: Optional[List[Dict[str, Any]]] = None self._roots: list[dict[str, Any]] | None = None
self._selected_root_path: Optional[Path] = None self._selected_root_path: Path | None = None
self._initialized = False self._initialized = False
async def initialize_with_context(self, ctx: Context) -> bool: async def initialize_with_context(self, ctx: Context) -> bool:
@ -64,7 +65,7 @@ class RootsAwareConfig:
self._initialized = True self._initialized = True
return False return False
def _select_best_root(self) -> Optional[Path]: def _select_best_root(self) -> Path | None:
"""Select the best root for Arduino sketches""" """Select the best root for Arduino sketches"""
if not self._roots: if not self._roots:
return None return None
@ -155,11 +156,11 @@ class RootsAwareConfig:
# Show source of directory # Show source of directory
if os.getenv('MCP_SKETCH_DIR'): if os.getenv('MCP_SKETCH_DIR'):
info.append(f" (from MCP_SKETCH_DIR env var)") info.append(" (from MCP_SKETCH_DIR env var)")
elif self._selected_root_path: elif self._selected_root_path:
info.append(f" (from MCP root)") info.append(" (from MCP root)")
else: else:
info.append(f" (default)") info.append(" (default)")
return "\n".join(info) return "\n".join(info)
@ -168,7 +169,7 @@ class RootsAwareConfig:
return getattr(self.base_config, name) return getattr(self.base_config, name)
def create_server(config: Optional[ArduinoServerConfig] = None) -> FastMCP: def create_server(config: ArduinoServerConfig | None = None) -> FastMCP:
""" """
Factory function to create a properly configured Arduino MCP server Factory function to create a properly configured Arduino MCP server
using the component pattern with automatic MCP roots detection. using the component pattern with automatic MCP roots detection.
@ -185,7 +186,7 @@ def create_server(config: Optional[ArduinoServerConfig] = None) -> FastMCP:
# Get package version for display # Get package version for display
try: try:
from importlib.metadata import version from importlib.metadata import version
package_version = version("mcp-arduino-server") package_version = version("mcp-arduino")
except: except:
package_version = "2025.09.26" package_version = "2025.09.26"
@ -217,6 +218,24 @@ def create_server(config: Optional[ArduinoServerConfig] = None) -> FastMCP:
# Initialize advanced components # Initialize advanced components
library_advanced = ArduinoLibrariesAdvanced(roots_config) library_advanced = ArduinoLibrariesAdvanced(roots_config)
board_advanced = ArduinoBoardsAdvanced(roots_config) board_advanced = ArduinoBoardsAdvanced(roots_config)
# Initialize debug component for client capability detection
try:
from .components.client_debug import ClientDebugInfo
client_debug = ClientDebugInfo(roots_config)
log.info("Client debug tool initialized")
except ImportError:
client_debug = None
log.info("Client debug tool not available")
# Initialize enhanced capabilities component
try:
from .components.client_capabilities import ClientCapabilitiesInfo
client_caps = ClientCapabilitiesInfo(roots_config)
log.info("Client capabilities tool initialized")
except ImportError:
client_caps = None
log.info("Client capabilities tool not available")
compile_advanced = ArduinoCompileAdvanced(roots_config) compile_advanced = ArduinoCompileAdvanced(roots_config)
system_advanced = ArduinoSystemAdvanced(roots_config) system_advanced = ArduinoSystemAdvanced(roots_config)
@ -234,9 +253,19 @@ def create_server(config: Optional[ArduinoServerConfig] = None) -> FastMCP:
compile_advanced.register_all(mcp) # Advanced compilation compile_advanced.register_all(mcp) # Advanced compilation
system_advanced.register_all(mcp) # System management system_advanced.register_all(mcp) # System management
# Register client debug component if available
if client_debug:
client_debug.register_all(mcp)
log.info("Client debug tools registered")
# Register client capabilities component if available
if client_caps:
client_caps.register_all(mcp)
log.info("Client capabilities tools registered")
# Add tool to show current directory configuration # Add tool to show current directory configuration
@mcp.tool(name="arduino_show_directories") @mcp.tool(name="arduino_show_directories")
async def show_directories(ctx: Context) -> Dict[str, Any]: async def show_directories(ctx: Context) -> dict[str, Any]:
"""Show current directory configuration including MCP roots status""" """Show current directory configuration including MCP roots status"""
await ensure_roots_initialized(ctx) await ensure_roots_initialized(ctx)
@ -357,8 +386,8 @@ def create_server(config: Optional[ArduinoServerConfig] = None) -> FastMCP:
log.info(f"🚀 Arduino Development Server v{package_version} initialized") log.info(f"🚀 Arduino Development Server v{package_version} initialized")
log.info(f"📁 Sketch directory: {config.sketches_base_dir}") log.info(f"📁 Sketch directory: {config.sketches_base_dir}")
log.info(f"🔧 Arduino CLI: {config.arduino_cli_path}") log.info(f"🔧 Arduino CLI: {config.arduino_cli_path}")
log.info(f"📚 Components loaded: Sketch, Library, Board, Debug, WireViz, Serial Monitor") log.info("📚 Components loaded: Sketch, Library, Board, Debug, WireViz, Serial Monitor")
log.info(f"📡 Serial monitoring: Enabled with cursor-based streaming") log.info("📡 Serial monitoring: Enabled with cursor-based streaming")
log.info(f"🤖 Client sampling: {'Enabled' if roots_config.enable_client_sampling else 'Disabled'}") log.info(f"🤖 Client sampling: {'Enabled' if roots_config.enable_client_sampling else 'Disabled'}")
log.info("📁 MCP Roots: Will be auto-detected on first tool use") log.info("📁 MCP Roots: Will be auto-detected on first tool use")
@ -398,4 +427,4 @@ def main():
if __name__ == "__main__": if __name__ == "__main__":
main() main()

View File

@ -1,277 +0,0 @@
"""
Arduino Server with MCP Roots support using FastMCP
This server uses client-provided roots for organizing sketches.
"""
import logging
from pathlib import Path
from typing import Optional
import os
from fastmcp import FastMCP, Context
from .config_with_roots import ArduinoServerConfigWithRoots
from .components import (
ArduinoBoard,
ArduinoDebug,
ArduinoLibrary,
ArduinoSketch,
WireViz,
)
from .components.arduino_serial import ArduinoSerial
from .components.arduino_libraries_advanced import ArduinoLibrariesAdvanced
from .components.arduino_boards_advanced import ArduinoBoardsAdvanced
from .components.arduino_compile_advanced import ArduinoCompileAdvanced
from .components.arduino_system_advanced import ArduinoSystemAdvanced
# Configure logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
def create_roots_aware_server(base_config: Optional[ArduinoServerConfigWithRoots] = None) -> FastMCP:
"""
Create an Arduino MCP server that uses client-provided roots for file organization.
"""
if base_config is None:
base_config = ArduinoServerConfigWithRoots()
# Get package version for display
try:
from importlib.metadata import version
package_version = version("mcp-arduino-server")
except:
package_version = "2025.09.26"
# Create the FastMCP server instance
mcp = FastMCP(
name="Arduino Development Server (Roots-Aware)"
)
# This will hold our configuration after roots are known
runtime_config = base_config
# Add initialization hook to configure with roots
@mcp.tool(name="arduino_initialize_with_roots")
async def initialize_with_roots(ctx: Context) -> dict:
"""
Initialize Arduino server with client-provided roots.
This should be called once when the client connects.
"""
nonlocal runtime_config
try:
# Get roots from context
roots = await ctx.list_roots()
logger.info(f"Received {len(roots) if roots else 0} roots from client")
# Select appropriate directory based on roots
sketch_dir = runtime_config.select_sketch_directory(roots)
# Ensure directories exist
runtime_config.ensure_directories()
return {
"success": True,
"sketch_directory": str(sketch_dir),
"roots_info": runtime_config.get_roots_info(),
"message": f"Arduino server initialized with sketch directory: {sketch_dir}"
}
except Exception as e:
logger.error(f"Failed to initialize with roots: {e}")
# Fallback to default
runtime_config.ensure_directories()
return {
"success": False,
"sketch_directory": str(runtime_config.default_sketches_dir),
"error": str(e),
"message": f"Using default directory: {runtime_config.default_sketches_dir}"
}
# Tool to get current configuration
@mcp.tool(name="arduino_get_active_directories")
async def get_active_directories(ctx: Context) -> dict:
"""Get the currently active directories being used by the server"""
return {
"sketch_directory": str(runtime_config.sketches_base_dir),
"build_directory": str(runtime_config.build_temp_dir),
"arduino_data": str(runtime_config.arduino_data_dir),
"arduino_user": str(runtime_config.arduino_user_dir),
"roots_configured": runtime_config._active_roots is not None,
"roots_info": runtime_config.get_roots_info()
}
# Enhanced sketch creation that respects roots
@mcp.tool(name="arduino_create_sketch_in_root")
async def create_sketch_in_root(
ctx: Context,
sketch_name: str,
root_name: Optional[str] = None
) -> dict:
"""
Create a sketch in a specific root or the selected directory.
Args:
sketch_name: Name of the sketch to create
root_name: Optional specific root to use (if multiple roots available)
"""
# If specific root requested, try to use it
if root_name and runtime_config._active_roots:
for root in runtime_config._active_roots:
if root.get('name', '').lower() == root_name.lower():
root_path = Path(root['uri'].replace('file://', ''))
sketch_dir = root_path / 'Arduino_Sketches'
sketch_dir.mkdir(parents=True, exist_ok=True)
# Create sketch in this specific root
sketch_path = sketch_dir / sketch_name
sketch_path.mkdir(parents=True, exist_ok=True)
# Create .ino file
ino_file = sketch_path / f"{sketch_name}.ino"
ino_file.write_text(f"""
void setup() {{
// Initialize serial communication
Serial.begin(115200);
Serial.println("{sketch_name} initialized");
}}
void loop() {{
// Main code here
}}
""")
return {
"success": True,
"path": str(sketch_path),
"root": root_name,
"message": f"Created sketch in root '{root_name}': {sketch_path}"
}
# Use default selected directory
sketch_path = runtime_config.sketches_base_dir / sketch_name
sketch_path.mkdir(parents=True, exist_ok=True)
# Create .ino file
ino_file = sketch_path / f"{sketch_name}.ino"
ino_file.write_text(f"""
void setup() {{
// Initialize serial communication
Serial.begin(115200);
Serial.println("{sketch_name} initialized");
}}
void loop() {{
// Main code here
}}
""")
return {
"success": True,
"path": str(sketch_path),
"root": "default",
"message": f"Created sketch in default location: {sketch_path}"
}
# Initialize all components with the runtime config
sketch = ArduinoSketch(runtime_config)
library = ArduinoLibrary(runtime_config)
board = ArduinoBoard(runtime_config)
debug = ArduinoDebug(runtime_config)
wireviz = WireViz(runtime_config)
serial = ArduinoSerial(runtime_config)
# Initialize advanced components
library_advanced = ArduinoLibrariesAdvanced(runtime_config)
board_advanced = ArduinoBoardsAdvanced(runtime_config)
compile_advanced = ArduinoCompileAdvanced(runtime_config)
system_advanced = ArduinoSystemAdvanced(runtime_config)
# Register all components
sketch.register_all(mcp)
library.register_all(mcp)
board.register_all(mcp)
debug.register_all(mcp)
wireviz.register_all(mcp)
serial.register_all(mcp)
# Register advanced components
library_advanced.register_all(mcp)
board_advanced.register_all(mcp)
compile_advanced.register_all(mcp)
system_advanced.register_all(mcp)
# Add server info resource that includes roots information
@mcp.resource(uri="server://info")
async def get_server_info() -> str:
"""Get information about the server configuration including roots"""
roots_status = "Configured" if runtime_config._active_roots else "Not configured (using defaults)"
return f"""
# Arduino Development Server v{package_version}
## Roots-Aware Edition
## MCP Roots:
{runtime_config.get_roots_info()}
## Configuration:
- Arduino CLI: {runtime_config.arduino_cli_path}
- Active Sketch Directory: {runtime_config.sketches_base_dir}
- Roots Status: {roots_status}
- WireViz: {runtime_config.wireviz_path}
- Client Sampling: {'Enabled' if runtime_config.enable_client_sampling else 'Disabled'}
## Components:
- **Sketch Management**: Create, compile, upload Arduino sketches (roots-aware)
- **Library Management**: Search, install, manage Arduino libraries
- **Board Management**: Detect boards, install cores
- **Debug Support**: GDB-like debugging with breakpoints and variable inspection
- **Circuit Diagrams**: Generate WireViz diagrams from YAML or natural language
- **Serial Monitoring**: Memory-safe serial monitoring with circular buffer
- **Advanced Tools**: 35+ new tools for professional Arduino development
## Usage:
1. Call 'arduino_initialize_with_roots' to configure with client roots
2. Use 'arduino_get_active_directories' to see current configuration
3. All sketch operations will use the configured root directories
"""
# Add a resource showing roots configuration
@mcp.resource(uri="arduino://roots")
async def get_roots_config() -> str:
"""Get current roots configuration"""
return runtime_config.get_roots_info()
logger.info("Arduino Development Server (Roots-Aware) initialized")
logger.info("Call 'arduino_initialize_with_roots' to configure with client roots")
return mcp
# Main entry point
def main():
"""Main entry point for roots-aware server"""
# Check for environment variable to enable roots support
use_roots = os.getenv("ARDUINO_USE_MCP_ROOTS", "true").lower() == "true"
preferred_root = os.getenv("ARDUINO_PREFERRED_ROOT")
if use_roots:
config = ArduinoServerConfigWithRoots(
preferred_root_name=preferred_root
)
server = create_roots_aware_server(config)
logger.info("Starting Arduino server with MCP roots support")
else:
# Fall back to standard server if roots disabled
from .server_refactored import create_server
from .config import ArduinoServerConfig
config = ArduinoServerConfig()
server = create_server(config)
logger.info("Starting Arduino server without roots support")
server.run()
if __name__ == "__main__":
main()

View File

@ -1,75 +0,0 @@
#!/usr/bin/env python3
"""Test dependency checker directly"""
import json
import subprocess
def test_deps(library_name):
"""Test dependency checking logic"""
# Run Arduino CLI command
cmd = ["arduino-cli", "lib", "deps", library_name, "--json"]
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
print(f"Error: {result.stderr}")
return
# Parse JSON
data = json.loads(result.stdout)
print(f"Raw data: {json.dumps(data, indent=2)}")
# Process dependencies
deps_info = {
"library": library_name,
"dependencies": [],
"missing": [],
"installed": [],
}
# Extract dependency information
all_deps = data.get("dependencies", [])
print(f"\nProcessing {len(all_deps)} dependencies for {library_name}")
for dep in all_deps:
dep_name = dep.get("name", "")
print(f" Checking: {dep_name}")
# Skip self-reference (library listing itself)
if dep_name == library_name:
print(f" -> Skipping self-reference")
continue
# Determine if installed based on presence of version_installed
is_installed = bool(dep.get("version_installed"))
dep_info = {
"name": dep_name,
"version_required": dep.get("version_required"),
"version_installed": dep.get("version_installed"),
"installed": is_installed
}
print(f" -> Adding as dependency: installed={is_installed}")
deps_info["dependencies"].append(dep_info)
if is_installed:
deps_info["installed"].append(dep_name)
else:
deps_info["missing"].append(dep_name)
print(f"\nFinal result:")
print(f" Dependencies: {deps_info['dependencies']}")
print(f" Installed: {deps_info['installed']}")
print(f" Missing: {deps_info['missing']}")
return deps_info
if __name__ == "__main__":
print("Testing ArduinoJson:")
test_deps("ArduinoJson")
print("\n" + "="*50 + "\n")
print("Testing PubSubClient:")
test_deps("PubSubClient")

View File

@ -1,65 +0,0 @@
#!/usr/bin/env python3
"""Test the fixed Arduino compile advanced functionality"""
import asyncio
import sys
from pathlib import Path
# Add the source directory to path
sys.path.insert(0, str(Path(__file__).parent / "src"))
from mcp_arduino_server.config import ArduinoServerConfig
from mcp_arduino_server.components.arduino_compile_advanced import ArduinoCompileAdvanced
async def test_compile_and_size():
"""Test the compilation and size analysis tools"""
# Initialize configuration
config = ArduinoServerConfig()
# Create compile component
compiler = ArduinoCompileAdvanced(config)
print("Testing arduino_compile_advanced...")
# Test compilation
compile_result = await compiler.compile_advanced(
sketch_name="TestCompile",
fqbn="esp32:esp32:esp32",
warnings="all",
export_binaries=True,
build_properties=None,
build_cache_path=None,
libraries=None,
optimize_for_debug=False,
preprocess_only=False,
show_properties=False,
verbose=False,
vid_pid=None,
jobs=None,
clean=False,
ctx=None
)
print(f"Compile result: {compile_result}")
print(f"Build path: {compile_result.get('build_path')}")
print("\nTesting arduino_size_analysis...")
# Test size analysis
size_result = await compiler.analyze_size(
sketch_name="TestCompile",
fqbn="esp32:esp32:esp32",
build_path=None,
detailed=True,
ctx=None
)
print(f"Size analysis result: {size_result}")
if size_result.get("success"):
print(f"Flash used: {size_result.get('flash_used')} bytes")
print(f"RAM used: {size_result.get('ram_used')} bytes")
if __name__ == "__main__":
asyncio.run(test_compile_and_size())

View File

@ -1,65 +0,0 @@
#!/usr/bin/env python3
"""Test MCP roots functionality"""
import asyncio
import sys
from pathlib import Path
# Add source to path
sys.path.insert(0, str(Path(__file__).parent / "src"))
from mcp_arduino_server.config import ArduinoServerConfig
from mcp_arduino_server.server_refactored import RootsAwareConfig
async def test_roots_config():
"""Test the roots-aware configuration"""
# Create base config
base_config = ArduinoServerConfig()
print(f"Base sketch dir: {base_config.sketches_base_dir}")
# Create roots-aware wrapper
roots_config = RootsAwareConfig(base_config)
# Test without roots (should use default)
print(f"\nWithout roots initialization:")
print(f" Sketch dir: {roots_config.sketches_base_dir}")
print(f" Is initialized: {roots_config._initialized}")
# Simulate MCP roots
class MockContext:
async def list_roots(self):
return [
{
"name": "my-arduino-projects",
"uri": "file:///home/user/projects/arduino"
},
{
"name": "dev-workspace",
"uri": "file:///home/user/workspace"
}
]
# Test with roots
mock_ctx = MockContext()
await roots_config.initialize_with_context(mock_ctx)
print(f"\nWith roots initialization:")
print(f" Sketch dir: {roots_config.sketches_base_dir}")
print(f" Is initialized: {roots_config._initialized}")
print(f"\nRoots info:")
print(roots_config.get_roots_info())
# Test environment variable override
import os
os.environ["MCP_SKETCH_DIR"] = "/tmp/test_sketches"
# Create new config to test env var
roots_config2 = RootsAwareConfig(base_config)
print(f"\nWith MCP_SKETCH_DIR env var:")
print(f" Sketch dir: {roots_config2.sketches_base_dir}")
if __name__ == "__main__":
asyncio.run(test_roots_config())

View File

@ -1,190 +0,0 @@
#!/usr/bin/env python3
"""Test MCP roots functionality without full imports"""
import asyncio
import sys
from pathlib import Path
# Add source to path
sys.path.insert(0, str(Path(__file__).parent / "src"))
# Import only what we need directly
import logging
import os
from typing import Optional, List, Dict, Any
logging.basicConfig(level=logging.INFO)
log = logging.getLogger(__name__)
class ArduinoServerConfig:
"""Minimal config for testing"""
def __init__(self):
self.sketches_base_dir = Path.home() / "Documents" / "Arduino_MCP_Sketches"
self.arduino_data_dir = Path.home() / ".arduino15"
self.arduino_user_dir = Path.home() / "Documents" / "Arduino"
class RootsAwareConfig:
"""Copied from server_refactored.py for testing"""
def __init__(self, base_config):
self.base_config = base_config
self._roots: Optional[List[Dict[str, Any]]] = None
self._selected_root_path: Optional[Path] = None
self._initialized = False
async def initialize_with_context(self, ctx):
"""Initialize with MCP context to get roots"""
try:
# Try to get roots from context
self._roots = await ctx.list_roots()
if self._roots:
log.info(f"Found {len(self._roots)} MCP roots from client")
# Select best root for Arduino sketches
selected_path = self._select_best_root()
if selected_path:
self._selected_root_path = selected_path
log.info(f"Using MCP root for sketches: {selected_path}")
self._initialized = True
return True
else:
log.info("No MCP roots provided by client")
except Exception as e:
log.debug(f"Could not get MCP roots: {e}")
self._initialized = True
return False
def _select_best_root(self) -> Optional[Path]:
"""Select the best root for Arduino sketches"""
if not self._roots:
return None
for root in self._roots:
try:
root_name = root.get('name', '').lower()
root_uri = root.get('uri', '')
if not root_uri.startswith('file://'):
continue
root_path = Path(root_uri.replace('file://', ''))
# Priority 1: Root named 'arduino'
if 'arduino' in root_name:
log.info(f"Selected Arduino-specific root: {root_name}")
return root_path / 'sketches'
# Priority 2: Root named 'projects' or 'code'
if any(term in root_name for term in ['project', 'code', 'dev']):
log.info(f"Selected development root: {root_name}")
return root_path / 'Arduino_Sketches'
except Exception as e:
log.warning(f"Error processing root {root}: {e}")
continue
# Use first available root as fallback
if self._roots:
first_root = self._roots[0]
root_uri = first_root.get('uri', '')
if root_uri.startswith('file://'):
root_path = Path(root_uri.replace('file://', ''))
log.info(f"Using first available root: {first_root.get('name')}")
return root_path / 'Arduino_Sketches'
return None
@property
def sketches_base_dir(self) -> Path:
"""Get sketches directory (roots-aware)"""
if self._initialized and self._selected_root_path:
return self._selected_root_path
# Check environment variable override
env_sketch_dir = os.getenv('MCP_SKETCH_DIR')
if env_sketch_dir:
return Path(env_sketch_dir).expanduser()
# Fall back to base config default
return self.base_config.sketches_base_dir
def get_roots_info(self) -> str:
"""Get information about roots configuration"""
info = []
if self._roots:
info.append(f"MCP Roots Available: {len(self._roots)}")
for root in self._roots:
name = root.get('name', 'unnamed')
uri = root.get('uri', 'unknown')
info.append(f" - {name}: {uri}")
else:
info.append("MCP Roots: Not available")
info.append(f"Active Sketch Dir: {self.sketches_base_dir}")
if os.getenv('MCP_SKETCH_DIR'):
info.append(f" (from MCP_SKETCH_DIR env var)")
elif self._selected_root_path:
info.append(f" (from MCP root)")
else:
info.append(f" (default)")
return "\n".join(info)
async def test_roots_config():
"""Test the roots-aware configuration"""
# Create base config
base_config = ArduinoServerConfig()
print(f"Base sketch dir: {base_config.sketches_base_dir}")
# Create roots-aware wrapper
roots_config = RootsAwareConfig(base_config)
# Test without roots (should use default)
print(f"\nWithout roots initialization:")
print(f" Sketch dir: {roots_config.sketches_base_dir}")
print(f" Is initialized: {roots_config._initialized}")
# Simulate MCP roots
class MockContext:
async def list_roots(self):
return [
{
"name": "my-arduino-projects",
"uri": "file:///home/user/projects/arduino"
},
{
"name": "dev-workspace",
"uri": "file:///home/user/workspace"
}
]
# Test with roots
mock_ctx = MockContext()
await roots_config.initialize_with_context(mock_ctx)
print(f"\nWith roots initialization:")
print(f" Sketch dir: {roots_config.sketches_base_dir}")
print(f" Is initialized: {roots_config._initialized}")
print(f"\nRoots info:")
print(roots_config.get_roots_info())
# Test environment variable override
os.environ["MCP_SKETCH_DIR"] = "/tmp/test_sketches"
# Create new config to test env var
roots_config2 = RootsAwareConfig(base_config)
print(f"\nWith MCP_SKETCH_DIR env var:")
print(f" Sketch dir: {roots_config2.sketches_base_dir}")
if __name__ == "__main__":
asyncio.run(test_roots_config())

View File

@ -1 +1 @@
"""Test suite for MCP Arduino Server""" """Test suite for MCP Arduino Server"""

View File

@ -1,25 +1,22 @@
""" """
Pytest configuration and fixtures for mcp-arduino-server tests Pytest configuration and fixtures for mcp-arduino-server tests
""" """
import os
import shutil
import tempfile import tempfile
from collections.abc import Generator
from pathlib import Path from pathlib import Path
from typing import Generator from unittest.mock import AsyncMock, Mock, patch
from unittest.mock import Mock, AsyncMock, patch
import pytest import pytest
from fastmcp import Context from fastmcp import Context
from fastmcp.utilities.tests import run_server_in_process
from mcp_arduino_server.config import ArduinoServerConfig
from mcp_arduino_server.components import ( from mcp_arduino_server.components import (
ArduinoSketch,
ArduinoLibrary,
ArduinoBoard, ArduinoBoard,
ArduinoDebug, ArduinoDebug,
WireViz ArduinoLibrary,
ArduinoSketch,
WireViz,
) )
from mcp_arduino_server.config import ArduinoServerConfig
@pytest.fixture @pytest.fixture
@ -251,4 +248,4 @@ def assert_logged_info(ctx: Mock, message_fragment: str):
for call in ctx.info.call_args_list: for call in ctx.info.call_args_list:
if message_fragment in str(call): if message_fragment in str(call):
return return
assert False, f"Info message containing '{message_fragment}' not found in logs" assert False, f"Info message containing '{message_fragment}' not found in logs"

View File

@ -16,6 +16,7 @@ sys.path.insert(0, '/home/rpm/claude/mcp-arduino-server/src')
from mcp_arduino_server.components.circular_buffer import CircularSerialBuffer, SerialDataType from mcp_arduino_server.components.circular_buffer import CircularSerialBuffer, SerialDataType
async def demo(): async def demo():
"""Demonstrate circular buffer behavior""" """Demonstrate circular buffer behavior"""
@ -39,7 +40,7 @@ async def demo():
# Create cursor at oldest data # Create cursor at oldest data
cursor1 = buffer.create_cursor(start_from="oldest") cursor1 = buffer.create_cursor(start_from="oldest")
print(f"\n✓ Created cursor1 at oldest data") print("\n✓ Created cursor1 at oldest data")
# Read first 5 entries # Read first 5 entries
result = buffer.read_from_cursor(cursor1, limit=5) result = buffer.read_from_cursor(cursor1, limit=5)
@ -65,7 +66,7 @@ async def demo():
# Check cursor status # Check cursor status
cursor_info = buffer.get_cursor_info(cursor1) cursor_info = buffer.get_cursor_info(cursor1)
print(f"\n🔍 Cursor1 status after wraparound:") print("\n🔍 Cursor1 status after wraparound:")
print(f" Valid: {cursor_info['is_valid']}") print(f" Valid: {cursor_info['is_valid']}")
print(f" Position: {cursor_info['position']}") print(f" Position: {cursor_info['position']}")
@ -81,7 +82,7 @@ async def demo():
# Create new cursor and demonstrate concurrent reading # Create new cursor and demonstrate concurrent reading
cursor2 = buffer.create_cursor(start_from="newest") cursor2 = buffer.create_cursor(start_from="newest")
print(f"\n✓ Created cursor2 at newest data") print("\n✓ Created cursor2 at newest data")
print("\n📊 Final Statistics:") print("\n📊 Final Statistics:")
stats = buffer.get_statistics() stats = buffer.get_statistics()
@ -90,7 +91,7 @@ async def demo():
# Cleanup # Cleanup
buffer.cleanup_invalid_cursors() buffer.cleanup_invalid_cursors()
print(f"\n🧹 Cleaned up invalid cursors") print("\n🧹 Cleaned up invalid cursors")
if __name__ == "__main__": if __name__ == "__main__":
asyncio.run(demo()) asyncio.run(demo())

View File

@ -6,19 +6,20 @@ Tests connection, reading, and cursor-based pagination
import asyncio import asyncio
import json import json
from pathlib import Path
import sys import sys
from pathlib import Path
# Add src to path for imports # Add src to path for imports
sys.path.insert(0, str(Path(__file__).parent / "src")) sys.path.insert(0, str(Path(__file__).parent / "src"))
from mcp_arduino_server.components.serial_monitor import (
SerialMonitorContext,
SerialListPortsTool,
SerialListPortsParams
)
from fastmcp import Context from fastmcp import Context
from mcp_arduino_server.components.serial_monitor import (
SerialListPortsParams,
SerialListPortsTool,
SerialMonitorContext,
)
async def test_serial_monitor(): async def test_serial_monitor():
"""Test serial monitor functionality""" """Test serial monitor functionality"""
@ -60,4 +61,4 @@ async def test_serial_monitor():
if __name__ == "__main__": if __name__ == "__main__":
asyncio.run(test_serial_monitor()) asyncio.run(test_serial_monitor())

View File

@ -0,0 +1,101 @@
#!/usr/bin/env python3
"""
Test script for WireViz sampling functionality
This tests the dual-mode approach:
1. Clients with sampling support get AI-generated diagrams
2. Clients without sampling get intelligent templates
"""
import asyncio
import logging
# Setup logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
log = logging.getLogger(__name__)
async def test_wireviz_generation():
"""Test WireViz generation with different descriptions"""
# Import the necessary components
from src.mcp_arduino_server.components.wireviz import WireViz
from src.mcp_arduino_server.config import ArduinoServerConfig
# Create config and component
config = ArduinoServerConfig()
wireviz = WireViz(config)
# Test descriptions
test_cases = [
{
"description": "Arduino with LED on pin 9",
"expected_template": "led"
},
{
"description": "Connect a servo motor to Arduino pin D9",
"expected_template": "motor"
},
{
"description": "Temperature sensor connected to analog pin A0",
"expected_template": "sensor"
},
{
"description": "Push button with pull-up resistor on D2",
"expected_template": "button"
},
{
"description": "I2C OLED display connected to Arduino",
"expected_template": "display" # Contains 'oled' which triggers display template
},
{
"description": "Generic circuit with resistors and capacitors",
"expected_template": "generic"
}
]
print("\n" + "="*60)
print("TESTING WIREVIZ TEMPLATE GENERATION")
print("="*60 + "\n")
for test in test_cases:
print(f"Test: {test['description']}")
print(f"Expected template type: {test['expected_template']}")
# Generate template (simulating no sampling available)
yaml_content = wireviz._generate_template_yaml(test['description'])
# Check if the right template was selected
if test['expected_template'] == 'led' and 'LED' in yaml_content:
print("✅ LED template correctly selected")
elif test['expected_template'] == 'motor' and 'Servo' in yaml_content:
print("✅ Motor/Servo template correctly selected")
elif test['expected_template'] == 'sensor' and 'Sensor' in yaml_content:
print("✅ Sensor template correctly selected")
elif test['expected_template'] == 'button' and 'Button' in yaml_content:
print("✅ Button template correctly selected")
elif test['expected_template'] == 'display' and 'I2C Display' in yaml_content:
print("✅ Display template correctly selected")
elif test['expected_template'] == 'generic' and 'Component1' in yaml_content:
print("✅ Generic template correctly selected")
else:
print("❌ Template selection might be incorrect")
print("-" * 40)
print("\n" + "="*60)
print("TEMPLATE GENERATION TEST COMPLETE")
print("="*60)
print("\nKey Insights:")
print("• Templates are selected based on keywords in the description")
print("• Each template provides a good starting point for common circuits")
print("• Users can customize the YAML for their specific needs")
print("• When sampling IS available, AI will generate custom YAML")
if __name__ == "__main__":
asyncio.run(test_wireviz_generation())

View File

@ -2,15 +2,12 @@
Tests for ArduinoBoard component Tests for ArduinoBoard component
""" """
import json import json
from unittest.mock import Mock, AsyncMock, patch
import subprocess import subprocess
from unittest.mock import AsyncMock, Mock, patch
import pytest import pytest
from tests.conftest import ( from tests.conftest import assert_logged_info, assert_progress_reported
assert_progress_reported,
assert_logged_info
)
class TestArduinoBoard: class TestArduinoBoard:
@ -377,4 +374,4 @@ class TestArduinoBoard:
assert "error" in result assert "error" in result
assert "Board search failed" in result["error"] assert "Board search failed" in result["error"]
assert "Invalid search term" in result["stderr"] assert "Invalid search term" in result["stderr"]

View File

@ -1,20 +1,18 @@
""" """
Tests for ArduinoDebug component Tests for ArduinoDebug component
""" """
import json
import asyncio import asyncio
from pathlib import Path import json
from unittest.mock import Mock, AsyncMock, patch, MagicMock from unittest.mock import AsyncMock, Mock, patch
import subprocess
import shutil
import pytest import pytest
from src.mcp_arduino_server.components.arduino_debug import ArduinoDebug, DebugCommand, BreakpointRequest from src.mcp_arduino_server.components.arduino_debug import (
from tests.conftest import ( ArduinoDebug,
assert_progress_reported, BreakpointRequest,
assert_logged_info DebugCommand,
) )
from tests.conftest import assert_logged_info, assert_progress_reported
class TestArduinoDebug: class TestArduinoDebug:
@ -836,4 +834,4 @@ class TestArduinoDebug:
assert component.pyadebug_path is None assert component.pyadebug_path is None
# Check that warning was logged # Check that warning was logged
assert any("PyArduinoDebug not found" in record.message for record in caplog.records) assert any("PyArduinoDebug not found" in record.message for record in caplog.records)

View File

@ -1,17 +1,13 @@
""" """
Tests for ArduinoLibrary component Tests for ArduinoLibrary component
""" """
import json
from pathlib import Path
from unittest.mock import Mock, patch, AsyncMock, MagicMock
import asyncio import asyncio
import json
from unittest.mock import AsyncMock, MagicMock
import pytest import pytest
from tests.conftest import ( from tests.conftest import assert_logged_info, assert_progress_reported
assert_progress_reported,
assert_logged_info
)
class TestArduinoLibrary: class TestArduinoLibrary:
@ -383,4 +379,4 @@ class TestArduinoLibrary:
) )
assert "error" in result assert "error" in result
assert "timed out" in result["error"] assert "timed out" in result["error"]

View File

@ -1,17 +1,11 @@
""" """
Tests for ArduinoSketch component Tests for ArduinoSketch component
""" """
import json from unittest.mock import patch
from pathlib import Path
from unittest.mock import Mock, patch, AsyncMock
import pytest import pytest
from tests.conftest import ( from tests.conftest import create_sketch_directory
create_sketch_directory,
assert_progress_reported,
assert_logged_info
)
class TestArduinoSketch: class TestArduinoSketch:
@ -311,4 +305,4 @@ class TestArduinoSketch:
) )
assert "error" in result assert "error" in result
assert "not allowed" in result["error"] assert "not allowed" in result["error"]

View File

@ -1,11 +1,12 @@
"""Test ESP32 core installation functionality""" """Test ESP32 core installation functionality"""
import asyncio import asyncio
from unittest.mock import AsyncMock, MagicMock, Mock, patch
import pytest import pytest
from unittest.mock import Mock, AsyncMock, patch, MagicMock
from fastmcp import Context from fastmcp import Context
from mcp_arduino_server.config import ArduinoServerConfig
from mcp_arduino_server.components.arduino_board import ArduinoBoard from mcp_arduino_server.components.arduino_board import ArduinoBoard
from mcp_arduino_server.config import ArduinoServerConfig
@pytest.mark.asyncio @pytest.mark.asyncio
@ -233,4 +234,4 @@ async def test_install_esp32_progress_tracking():
if __name__ == "__main__": if __name__ == "__main__":
pytest.main([__file__, "-v"]) pytest.main([__file__, "-v"])

View File

@ -19,16 +19,15 @@ import asyncio
import json import json
import tempfile import tempfile
from pathlib import Path from pathlib import Path
from unittest.mock import Mock, patch, AsyncMock from unittest.mock import AsyncMock, Mock, patch
from typing import Dict, Any
import pytest import pytest
from fastmcp import Client from fastmcp import Client
from fastmcp.client.transports import StreamableHttpTransport from fastmcp.client.transports import StreamableHttpTransport
from fastmcp.utilities.tests import run_server_in_process from fastmcp.utilities.tests import run_server_in_process
from src.mcp_arduino_server.server_refactored import create_server
from src.mcp_arduino_server.config import ArduinoServerConfig from src.mcp_arduino_server.config import ArduinoServerConfig
from src.mcp_arduino_server.server_refactored import create_server
def create_test_server(host: str, port: int, transport: str = "http") -> None: def create_test_server(host: str, port: int, transport: str = "http") -> None:
@ -566,4 +565,4 @@ class TestESP32InstallationIntegration:
if __name__ == "__main__": if __name__ == "__main__":
# Run this specific test file # Run this specific test file
import sys import sys
sys.exit(pytest.main([__file__, "-v", "-s"])) sys.exit(pytest.main([__file__, "-v", "-s"]))

View File

@ -14,18 +14,16 @@ This test is intended to be run manually when testing the ESP32 installation
functionality, as it requires internet connectivity and downloads large packages. functionality, as it requires internet connectivity and downloads large packages.
""" """
import asyncio
import tempfile import tempfile
from pathlib import Path from pathlib import Path
from unittest.mock import Mock
import pytest import pytest
from fastmcp import Client from fastmcp import Client
from fastmcp.client.transports import StreamableHttpTransport from fastmcp.client.transports import StreamableHttpTransport
from fastmcp.utilities.tests import run_server_in_process from fastmcp.utilities.tests import run_server_in_process
from src.mcp_arduino_server.server_refactored import create_server
from src.mcp_arduino_server.config import ArduinoServerConfig from src.mcp_arduino_server.config import ArduinoServerConfig
from src.mcp_arduino_server.server_refactored import create_server
def create_test_server(host: str, port: int, transport: str = "http") -> None: def create_test_server(host: str, port: int, transport: str = "http") -> None:
@ -288,4 +286,4 @@ if __name__ == "__main__":
"-v", "-s", "-v", "-s",
"-m", "not slow", # Skip slow tests by default "-m", "not slow", # Skip slow tests by default
"--tb=short" "--tb=short"
])) ]))

View File

@ -7,12 +7,13 @@ component testing with comprehensive mocking.
""" """
import asyncio import asyncio
from unittest.mock import AsyncMock, MagicMock, Mock, patch
import pytest import pytest
from unittest.mock import Mock, AsyncMock, patch, MagicMock
from fastmcp import Context from fastmcp import Context
from src.mcp_arduino_server.config import ArduinoServerConfig
from src.mcp_arduino_server.components.arduino_board import ArduinoBoard from src.mcp_arduino_server.components.arduino_board import ArduinoBoard
from src.mcp_arduino_server.config import ArduinoServerConfig
class TestESP32InstallationUnit: class TestESP32InstallationUnit:
@ -410,4 +411,4 @@ class TestESP32InstallationUnit:
if __name__ == "__main__": if __name__ == "__main__":
# Run the unit tests # Run the unit tests
import sys import sys
sys.exit(pytest.main([__file__, "-v", "-s"])) sys.exit(pytest.main([__file__, "-v", "-s"]))

View File

@ -5,13 +5,11 @@ These tests verify server architecture and component integration
without requiring full MCP protocol simulation. without requiring full MCP protocol simulation.
""" """
import tempfile
from pathlib import Path
import pytest import pytest
from src.mcp_arduino_server.server_refactored import create_server
from src.mcp_arduino_server.config import ArduinoServerConfig from src.mcp_arduino_server.config import ArduinoServerConfig
from src.mcp_arduino_server.server_refactored import create_server
class TestServerIntegration: class TestServerIntegration:
@ -111,8 +109,11 @@ class TestServerIntegration:
def test_component_isolation(self, test_config): def test_component_isolation(self, test_config):
"""Test that components can be created independently""" """Test that components can be created independently"""
from src.mcp_arduino_server.components import ( from src.mcp_arduino_server.components import (
ArduinoSketch, ArduinoLibrary, ArduinoBoard, ArduinoBoard,
ArduinoDebug, WireViz ArduinoDebug,
ArduinoLibrary,
ArduinoSketch,
WireViz,
) )
# Each component should initialize without errors # Each component should initialize without errors
@ -199,4 +200,4 @@ class TestServerIntegration:
# Each scheme should have reasonable number of resources # Each scheme should have reasonable number of resources
assert len(schemes['arduino']) >= 3, "Too few arduino:// resources" assert len(schemes['arduino']) >= 3, "Too few arduino:// resources"
assert len(schemes['wireviz']) >= 1, "Too few wireviz:// resources" assert len(schemes['wireviz']) >= 1, "Too few wireviz:// resources"
assert len(schemes['server']) >= 1, "Too few server:// resources" assert len(schemes['server']) >= 1, "Too few server:// resources"

View File

@ -12,16 +12,15 @@ import asyncio
import json import json
import tempfile import tempfile
from pathlib import Path from pathlib import Path
from unittest.mock import Mock, patch from unittest.mock import patch
from typing import Dict, Any
import pytest import pytest
from fastmcp import Client from fastmcp import Client
from fastmcp.client.transports import StreamableHttpTransport from fastmcp.client.transports import StreamableHttpTransport
from fastmcp.utilities.tests import run_server_in_process from fastmcp.utilities.tests import run_server_in_process
from src.mcp_arduino_server.server_refactored import create_server
from src.mcp_arduino_server.config import ArduinoServerConfig from src.mcp_arduino_server.config import ArduinoServerConfig
from src.mcp_arduino_server.server_refactored import create_server
def create_test_server(host: str, port: int, transport: str = "http") -> None: def create_test_server(host: str, port: int, transport: str = "http") -> None:
@ -330,4 +329,4 @@ class TestPerformanceIntegration:
for result in results: for result in results:
assert not isinstance(result, Exception), f"Rapid call failed: {result}" assert not isinstance(result, Exception), f"Rapid call failed: {result}"
# Most calls should succeed (some might have mocking conflicts but that's expected) # Most calls should succeed (some might have mocking conflicts but that's expected)
assert hasattr(result, 'data') assert hasattr(result, 'data')

View File

@ -5,14 +5,11 @@ These tests focus on verifying server architecture, component integration,
and metadata consistency without requiring full MCP protocol simulation. and metadata consistency without requiring full MCP protocol simulation.
""" """
import tempfile
from pathlib import Path
from unittest.mock import patch
import pytest import pytest
from src.mcp_arduino_server.server_refactored import create_server
from src.mcp_arduino_server.config import ArduinoServerConfig from src.mcp_arduino_server.config import ArduinoServerConfig
from src.mcp_arduino_server.server_refactored import create_server
class TestServerArchitecture: class TestServerArchitecture:
@ -152,8 +149,11 @@ class TestServerArchitecture:
def test_component_isolation(self, test_config): def test_component_isolation(self, test_config):
"""Test that components can be created independently""" """Test that components can be created independently"""
from src.mcp_arduino_server.components import ( from src.mcp_arduino_server.components import (
ArduinoSketch, ArduinoLibrary, ArduinoBoard, ArduinoBoard,
ArduinoDebug, WireViz ArduinoDebug,
ArduinoLibrary,
ArduinoSketch,
WireViz,
) )
# Each component should initialize without errors # Each component should initialize without errors
@ -353,4 +353,4 @@ class TestComponentIntegration:
# All components should use the same config # All components should use the same config
# This is tested implicitly by successful server creation # This is tested implicitly by successful server creation
assert server is not None assert server is not None
assert config.sketches_base_dir.exists() assert config.sketches_base_dir.exists()

View File

@ -1,20 +1,13 @@
""" """
Tests for WireViz component Tests for WireViz component
""" """
import base64
import os import os
from pathlib import Path
from unittest.mock import Mock, AsyncMock, patch, MagicMock
import subprocess import subprocess
import datetime from unittest.mock import AsyncMock, Mock, patch
import pytest import pytest
from src.mcp_arduino_server.components.wireviz import WireViz, WireVizRequest from src.mcp_arduino_server.components.wireviz import WireViz, WireVizRequest
from tests.conftest import (
assert_progress_reported,
assert_logged_info
)
class TestWireViz: class TestWireViz:
@ -549,4 +542,4 @@ connectors:
assert "Arduino:" in written_content assert "Arduino:" in written_content
assert "LED:" in written_content assert "LED:" in written_content
assert result["success"] is True assert result["success"] is True