This guide covers development setup, testing, code quality, and contribution guidelines for InstrMCP.
# Clone repository
git clone https://github.com/caidish/instrMCP.git
cd instrMCP
# Create conda environment
conda create -n instrMCPdev python=3.11 -y
conda activate instrMCPdev
# Install in development mode with dev dependencies
pip install -e .[dev]
# Set environment variable
export instrMCP_PATH="$(pwd)"# Run all tests
pytest tests/
# Run with coverage
pytest tests/ --cov=instrmcp --cov-report=html
# Run specific test file
pytest tests/unit/test_cache.py -v
# Skip slow tests
pytest tests/ -m "not slow"# Format code
black instrmcp/ tests/
# Check formatting
black --check instrmcp/ tests/
# Linting
flake8 instrmcp/ tests/
# Type checking
mypy instrmcp/ --ignore-missing-imports
# Run all checks
black instrmcp/ tests/ && \
flake8 instrmcp/ tests/ --extend-ignore=F824 && \
pytest tests/ -vThe project includes a comprehensive test suite with 377+ tests covering all major components.
- Unit tests:
tests/unit/- Isolated component tests - Integration tests:
tests/integration/- End-to-end workflows (planned) - Fixtures:
tests/fixtures/- Mock instruments, IPython, notebooks, databases - All tests use mocks - no hardware required!
pytest tests/ # All tests
pytest tests/ --cov=instrmcp --cov-report=html # With coverage- ✅ Automated testing on Python 3.10, 3.11, 3.12
- ✅ Tests run on Ubuntu & macOS
- ✅ Code quality checks (Black, Flake8, MyPy)
- ✅ Coverage reports uploaded to Codecov
See tests/README.md for detailed testing guide.
When making changes to MCP tools:
- Update
stdio_proxy.py: Add/remove tool proxies ininstrmcp/tools/stdio_proxy.py - Check
requirements.txt: Ensure new Python dependencies are listed - Update
pyproject.toml: Add dependencies and entry points as needed - Update README.md: Document new features or removed functionality
The server operates in two modes:
- Safe Mode: Read-only access to instruments and notebooks
- Unsafe Mode: Allows code execution in Jupyter cells
This is controlled via the safe_mode parameter in server initialization and the --unsafe CLI flag.
When running as a Jupyter extension, the system involves multiple threads and event loops:
┌─────────────────────────────────────────────────────────────────┐
│ IPython Kernel (Main Thread) │
│ ┌───────────────────────────────────────────────────────────┐ │
│ │ Tornado IOLoop (wraps asyncio) │ │
│ │ └── Qt Event Loop (integrated via %gui qt) │ │
│ │ └── MeasureIt Sweeps (QThread workers) │ │
│ └───────────────────────────────────────────────────────────┘ │
│ │ │
│ spawns │ │
│ ▼ │
│ ┌───────────────────────────────────────────────────────────┐ │
│ │ MCP Server Thread (_server_thread) │ │
│ │ └── asyncio event loop (separate from kernel) │ │
│ │ └── HTTP/SSE handlers │ │
│ └───────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
-
IPython Kernel (Main Thread)
- Runs Tornado's
AsyncIOMainLoopwhich wraps an asyncio event loop - When Qt GUI is enabled (
%gui qt), Qt's event loop is integrated - MeasureIt sweeps run on
QThreadworkers but UI operations must happen on the main thread
- Runs Tornado's
-
MCP Server Thread
- Started by
load_ipython_extension()in a separate daemon thread - Runs its own asyncio event loop for handling MCP protocol
- Cannot directly call Qt/MeasureIt methods due to thread-safety requirements
- Started by
-
MeasureIt Sweeps
- Each sweep runs in a
QThread(Qt's threading) - The
sweep.kill()method must be called from the sweep's Qt thread - Uses Qt signals/slots for cross-thread communication
- Each sweep runs in a
What DOES NOT work when MCP server needs to call MeasureIt from its thread:
| Approach | Why It Fails |
|---|---|
asyncio.call_soon_threadsafe() |
When Qt event loop is integrated with IPython, asyncio callbacks don't run while Qt is processing events |
tornado.ioloop.add_callback() |
Same issue - Tornado wraps asyncio, so callbacks are blocked |
asyncio.run_coroutine_threadsafe() |
Also blocked by Qt event loop integration |
QTimer.singleShot(0, callback) |
Not thread-safe when called from non-Qt thread |
What DOES work:
| Approach | Why It Works |
|---|---|
QMetaObject.invokeMethod(..., Qt.QueuedConnection) |
Thread-safe Qt mechanism that posts to the target object's event queue |
| Proxy QObject on target thread | Create a QObject on the sweep's thread, use moveToThread(), then invoke its slot |
For kill_sweep, we use a Qt proxy pattern that bypasses asyncio entirely:
class _KillProxy(QObject):
def __init__(self, target_sweep):
super().__init__()
self._sweep = target_sweep
@pyqtSlot()
def do_kill(self):
self._sweep.kill()
# Create proxy and move to sweep's thread
proxy = _KillProxy(sweep)
proxy.moveToThread(sweep.thread())
# Queue kill on sweep's Qt thread (thread-safe!)
QMetaObject.invokeMethod(proxy, "do_kill", Qt.QueuedConnection)This works because:
- The proxy
QObjectis moved to the sweep's Qt thread invokeMethodwithQueuedConnectionposts an event to that thread's event queue- When the sweep's thread processes events, it calls
do_kill()on the correct thread - This is the same mechanism MeasureIt uses internally for cross-thread operations
When debugging cross-thread issues:
- Check which event loop is active: IPython with Qt GUI uses Qt's event loop, not pure asyncio
- Verify thread affinity: Qt objects belong to specific threads; check with
obj.thread() - Use file-based logging: Console logging may not work reliably across threads
- Test with kernel idle: Some approaches work when kernel is busy but fail when idle
- ipykernel eventloops.py - How IPython integrates with Qt
- Qt Thread Safety - Qt's threading model and
invokeMethod - ipywidgets threading issues - Similar challenges with widget callbacks
The package includes a JupyterLab extension for active cell bridging:
- Located in
instrmcp/extensions/jupyterlab/ - Build workflow:
cd instrmcp/extensions/jupyterlab jlpm run build- The build automatically copies files to
mcp_active_cell_bridge/labextension/ - This ensures
pip install -e .will find the latest built files
- The build automatically copies files to
- Automatically installed with the main package
- Enables real-time cell content access for MCP tools
Important for development: After modifying TypeScript files, you must:
- Run
jlpm run buildin the extension directory - The postbuild script automatically copies files to the correct location
- Reinstall:
pip install -e . --force-reinstall --no-deps - Restart JupyterLab completely
- Environment variable:
instrMCP_PATHcan be set for custom paths - View configuration:
instrmcp config
- Fork the repository
- Create feature branch (
git checkout -b feature/amazing-feature) - Commit changes (
git commit -m 'Add amazing feature') - Push to branch (
git push origin feature/amazing-feature) - Open Pull Request
- Always test MCP tool changes with both safe and unsafe modes
- The caching system (
cache.py) prevents excessive instrument reads - Rate limiting protects instruments from command flooding
- The system supports hierarchical parameter access (e.g.,
ch01.voltage) - Jupyter cell tracking happens via IPython event hooks for real-time access
- Always use conda environment instrMCPdev for testing
- Remember to update stdio_proxy.py whenever we change the tools for mcp server
- Check requirements.txt when new python file is created
- Don't forget to update pyproject.toml
- Whenever delete or create a tool in mcp_server.py, update the hook in instrmcp.utils.stdio_proxy
- When removing features, update README.md
See .github/CONTRIBUTING.md for detailed contribution guidelines.