Skip to content

Fix GH Actions failures#36

Merged
ilan-theodoro merged 9 commits intomainfrom
fix/gh-actions-failures
Mar 17, 2026
Merged

Fix GH Actions failures#36
ilan-theodoro merged 9 commits intomainfrom
fix/gh-actions-failures

Conversation

@ilan-theodoro
Copy link
Copy Markdown
Member

@ilan-theodoro ilan-theodoro commented Mar 17, 2026

Summary

  • make the CLI help test resilient to ANSI-colored output in CI
  • fix the follow-on lint/type issues that appear once formatting passes
  • harden the Claude Code Review workflow to skip when GitHub refuses app-token validation because the workflow file differs from the default branch
  • make the advisory security scan truly non-blocking at the step level

Validation

  • ./.venv/bin/pytest tests/ --verbose --cov=napari_mcp --cov-report=xml --cov-report=term-missing --cov-fail-under=80 --durations=20 --timeout=60
  • ./.venv/bin/ruff check src/ tests/
  • ./.venv/bin/ruff format --check src/ tests/
  • ./.venv/bin/mypy src/napari_mcp/ --ignore-missing-imports

Merging this into loic-refactor should update the existing PR to main with the CI fixes.

Summary by CodeRabbit

  • New Features

    • Explicit install subcommand (napari-mcp-install install ) with optional napari backend selection.
    • Unified layer API: add_layer plus get_layer, set_layer_properties, reorder_layer, apply_to_layers, save_layer_data.
    • Enhanced viewer controls (configure_viewer) and improved execute_code with persisted outputs and read_output.
  • Bug Fixes & Improvements

    • Streamlined installer CLI (single install flow, ALL target) and clearer install/uninstall messages; consolidated tool surface.
  • Documentation

    • Updated docs and examples to reflect new CLI and tool names.

royerloic and others added 6 commits March 7, 2026 14:00
Major refactor of server architecture:
- Extract ServerState, ViewerProtocol, qt_helpers, and output modules
- Replace module-level globals with ServerState class
- Add STANDALONE/AUTO_DETECT startup modes
- Simplify CLI to single `install --target <name>` command
- Update bridge_server to use new state management

Test suite reorganization:
- Delete duplicate test files (test_coverage.py, test_napari_server_coverage.py)
- Rename test_edge_cases.py to test_qt_helpers.py (reflects actual content)
- Create test_server_tools.py as canonical home for tool unit tests
- Add tests for AUTO_DETECT proxy fallbacks, invalid inputs, concurrent access
- Add multi-step workflow integration tests
- Consolidate widget tests into test_widget.py
- Fix SIM117 lint warnings in test_bridge_server.py
- Remove raw napari API tests that don't test our code
- Net: 286 tests, 84.15% coverage (0 duplicates, 13 new tests)

CI and docs:
- Bump --cov-fail-under from 70% to 80%
- Update CONTRIBUTING.md test file listing
- Update documentation references

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
API consolidation:
- Merge add_image/add_labels/add_points into unified add_layer (all 7 types)
- Merge set_camera/set_ndisplay/set_dims_current_step/set_grid/reset_view
  into configure_viewer
- Absorb set_active_layer into set_layer_properties (active param)
- Absorb timelapse_screenshot into screenshot (axis/slice_range params)
- New tools: get_layer (metadata+data+slicing), apply_to_layers (batch ops),
  save_layer_data (npy/tiff/csv export)

Validation and error handling:
- Input validation for opacity (0-1), gamma (>0), zoom (>0), ndisplay (2-3)
- contrast_limits must be exactly [min, max] (reject wrong lengths)
- Invalid colormap/blending return error instead of crashing
- configure_viewer validates before mutating (no partial state on error)
- Dims values clamped to valid range with warning
- add_layer rejects conflicting data sources (data + data_var + path)

New shared helpers module (_helpers.py):
- parse_bool, resolve_layer_type, build_layer_detail, create_layer_on_viewer
- run_code (exec with stdout/stderr capture), build_truncated_response
- Eliminates duplication between server.py and bridge_server.py

Bridge server:
- Exclude lifecycle tools (init_viewer, close_viewer) in bridge mode
- Updated for add_layer API

Tests (454 passing, 80.71% coverage):
- test_server_tools.py rewritten: class-per-tool, ~100 tests
- test_helpers.py: unit tests for shared helpers
- test_widget.py: port change UI, server failure handling
- test_bridge_server.py: lifecycle exclusion, add_layer via bridge
- test_tools.py: tool completeness checks, README sync test

Other:
- Delete tox.ini (uv-based workflow)
- Add _helpers.py to ruff per-file ignores
- Update docs, CONTRIBUTING.md, codecov.yml

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
- Normalize line_limit from string to int with fallback to 30 in
  execute_code, install_packages, and build_truncated_response
- Remove _parse_bool/_truncate_output backward-compat aliases from
  server.py; tests now import directly from _helpers/output modules
- Add tests: numpy slicing edge cases, max_elements cap, line_limit
  normalization (string/invalid/None), save_layer_data format coverage
  (png, jpg, csv tracks, labels tiff), security package name validation
  (pip flag injection, URL specifiers, newline injection)

474 tests, 81.05% coverage.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Server shutdown:
- Add ServerState.request_shutdown() — schedules delayed loop.stop(),
  safe to call from any thread (Qt main thread on viewer destroy)
- Wire _on_destroyed signal and close_viewer to request_shutdown
- Capture event loop via FastMCP lifespan context manager
- Clean up shutdown state in conftest reset_server_state fixture

Bridge server fixes:
- Reject 'path' param for non-image/labels layer types
- Require 'data_var' for surface layers (no inline data support)
- Inject real napari module in exec namespace (was None)

Other:
- Fix cline_cursor install command hint (was missing 'install')
- Fix codecov threshold comment (said 10%, was actually 2%)
- Remove CHANGELOG.md from MANIFEST.in (doesn't exist)
- Rename CI step labels ("Test with tox" -> proper names)
- Make test_expected_tools_matches_server properly async
- Add benchmark marker to pyproject.toml

Tests (487 passing, 81.20% coverage):
- TestRequestShutdown: lifecycle, idempotency, closed loop, delayed stop
- TestBridgeAddLayerValidation: path rejection, surface, conflicts, unknown type
- TestBridgeExecNamespace: napari module injection, numpy availability
- test_close_viewer_requests_shutdown, test_on_destroyed_requests_shutdown

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 17, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: fbe3f11f-dba9-4227-98fc-f42557ea6cf8

📥 Commits

Reviewing files that changed from the base of the PR and between 052b5f0 and 515036c.

📒 Files selected for processing (1)
  • tests/test_tools.py

📝 Walkthrough

Walkthrough

Major refactor introducing ServerState and StartupMode, consolidating bridge tools (add_layer, execute_code), new helper modules for layer management and code execution, Qt event-loop helpers, unified CLI installer with an explicit "install" subcommand and napari_backend handling, extensive docs/CI updates, and large test suite reorganization.

Changes

Cohort / File(s) Summary
Workflows
\.github/workflows/claude-code-review.yml, \.github/workflows/docs.yml, \.github/workflows/release.yml, \.github/workflows/test.yml
Elevate PR permissions; add workflow-parity check to skip Claude review when workflows diverge; upgrade setup-uv action versions; add release tag validation and security advisory job; adjust caching/coverage thresholds.
Core state & protocols
src/napari_mcp/state.py, src/napari_mcp/viewer_protocol.py, src/napari_mcp/__init__.py
Add ServerState and StartupMode; move external-proxy/detection into state methods; introduce ViewerProtocol; expose new public symbols and guarded Qt imports; remove NapariMCPTools export.
Helpers & output
src/napari_mcp/_helpers.py, src/napari_mcp/output.py
New centralized helpers (parse_bool, resolve_layer_type, build_layer_detail, create_layer_on_viewer, run_code, build_truncated_response) and truncate_output utility for output truncation.
Qt integration
src/napari_mcp/qt_helpers.py, src/napari_mcp/widget.py
Ensure/create Qt app and viewer, event pump, connect window-destroyed handling, GUI execution helpers; widget UI messaging/start-failure behavior tweaks.
Bridge server consolidation
src/napari_mcp/bridge_server.py
Refactor NapariBridgeServer to use ServerState, run GUI ops on Qt main thread with timeout, replace many legacy tools with consolidated add_layer and updated execute_code, and use state-based output storage.
CLI installer & utils
src/napari_mcp/cli/main.py, src/napari_mcp/cli/install/*, src/napari_mcp/cli/install/utils.py
Unified install command with InstallTarget enum and installer factory; propagate napari_backend into build_server_config (napari_requirement); prompt/normalize backend choices; migrate TOML handling to tomllib/tomli; update installer classes to accept napari_backend.
Packaging & config
pyproject.toml, MANIFEST.in, codecov.yml, mkdocs.yml
Remove pyqt6 dependency, add tomli for older Pythons, consolidate extras, remove CHANGELOG from MANIFEST, lower codecov thresholds, update mkdocs navigation and copyright year.
Docs: CLI & APIs
README.md, docs/getting-started/*, docs/integrations/*, docs/integrations/python.md
Change documented CLI from napari-mcp-install <app> to napari-mcp-install install <app>, add --backend options, replace add_image with add_layer and list new tools; update examples and guides.
API reference generation
docs/scripts/gen_ref_pages.py
Switch static API reference generation to AST-based extraction from create_server to dynamically render tool signatures/docstrings and update generated navigation.
CLI installer tests & utils
tests/test_cli_installer.py, tests/test_cli_utils.py, tests/test_base_installer.py, tests/test_cli_installers/*
Update tests to new install subcommand, add InstallTarget/_get_installer_class tests, and cover napari_backend resolution and build_server_config napari_requirement behavior.
New/expanded tests: helpers/state/qt/tools
tests/test_helpers.py, tests/test_state.py, tests/test_qt_helpers.py, tests/test_server_tools.py
Large new test suites for _helpers, ServerState lifecycle and output eviction, Qt helper behaviors, and end-to-end tests for the consolidated 16 MCP tools with many edge cases.
Bridge & integration tests
tests/test_bridge_server.py, tests/test_external_viewer.py, tests/test_output_storage.py, tests/test_integration.py, tests/test_tools.py
Expand/refactor bridge and integration tests to new state-based APIs, standalone mode semantics, and state-based output storage/read paths.
Test removals & refactor
tests/test_coverage.py, tests/test_napari_server_coverage.py, tests/test_edge_cases.py, tests/test_bridge_simplified.py, tests/fixtures/async_utils.py, tox.ini
Remove several legacy large test modules, async test utilities, and tox.ini; refactor conftest to a state-aware reset fixture and remove fixtures all export.
Minor docs/examples & CI docs
docs/examples/*, docs/scripts/*, \.github/*
Small updates: model name change in an example, uv action version bumps in docs workflow, and example command adjustments.

Sequence Diagram(s)

sequenceDiagram
    participant CLI as "CLI Installer"
    participant Factory as "Installer Factory / BaseInstaller"
    participant Build as "build_server_config"
    participant Server as "MCP Server (create_server)"
    participant State as "ServerState"
    participant GUI as "Qt Main Thread"
    participant Tool as "Tool (add_layer / execute_code)"

    CLI->>Factory: install(target, napari_backend)
    Factory->>Build: build_server_config(..., napari_requirement)
    Build->>Server: create_server(state)
    Server->>State: initialize (mode, output storage, gui executor)
    Server->>Tool: register tools (add_layer, execute_code)

    Note over Tool,GUI: Tool execution runs on GUI main thread
    Tool->>State: request gui_execute(operation)
    State->>GUI: schedule operation on Qt main thread (with timeout)
    GUI-->>Tool: operation result / timeout
    Tool->>State: store_output(tool_name, stdout, stderr, result)
    State-->>Tool: return output_id
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs

Poem

🐰
I hopped through code to mend a seam,
State and helpers stitched the team.
"Install" leads where apps now go,
Layers bloom and outputs show.
Tests clap paws — the refactor gleams!

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/gh-actions-failures
📝 Coding Plan
  • Generate coding plan for human review comments

@ilan-theodoro ilan-theodoro changed the base branch from loic-refactor to main March 17, 2026 04:33
@github-actions
Copy link
Copy Markdown
Contributor

✅ Documentation builds successfully! The site will be updated when this PR is merged.

@github-actions
Copy link
Copy Markdown
Contributor

✅ Documentation builds successfully! The site will be updated when this PR is merged.

@codecov
Copy link
Copy Markdown

codecov bot commented Mar 17, 2026

Codecov Report

❌ Patch coverage is 82.19634% with 107 lines in your changes missing coverage. Please review.
✅ Project coverage is 78.87%. Comparing base (7743c8a) to head (515036c).
⚠️ Report is 8 commits behind head on main.

Files with missing lines Patch % Lines
src/napari_mcp/qt_helpers.py 65.51% 16 Missing and 4 partials ⚠️
src/napari_mcp/cli/main.py 79.77% 13 Missing and 5 partials ⚠️
src/napari_mcp/bridge_server.py 75.71% 14 Missing and 3 partials ⚠️
src/napari_mcp/state.py 88.88% 5 Missing and 7 partials ⚠️
src/napari_mcp/viewer_protocol.py 66.66% 10 Missing ⚠️
src/napari_mcp/_helpers.py 93.83% 5 Missing and 4 partials ⚠️
src/napari_mcp/cli/install/utils.py 82.97% 4 Missing and 4 partials ⚠️
src/napari_mcp/cli/install/codex_cli.py 65.00% 6 Missing and 1 partial ⚠️
src/napari_mcp/cli/install/base.py 66.66% 1 Missing and 1 partial ⚠️
src/napari_mcp/output.py 86.66% 2 Missing ⚠️
... and 1 more
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main      #36      +/-   ##
==========================================
+ Coverage   78.23%   78.87%   +0.63%     
==========================================
  Files          16       21       +5     
  Lines        1792     2248     +456     
  Branches      230      387     +157     
==========================================
+ Hits         1402     1773     +371     
- Misses        282      355      +73     
- Partials      108      120      +12     
Files with missing lines Coverage Δ
src/napari_mcp/__init__.py 100.00% <100.00%> (ø)
src/napari_mcp/cli/install/cline_cursor.py 83.87% <ø> (ø)
src/napari_mcp/cli/install/cursor.py 83.87% <ø> (ø)
src/napari_mcp/cli/install/gemini_cli.py 71.05% <ø> (ø)
src/napari_mcp/server.py 72.04% <ø> (-3.74%) ⬇️
src/napari_mcp/cli/install/base.py 82.22% <66.66%> (-0.14%) ⬇️
src/napari_mcp/output.py 86.66% <86.66%> (ø)
src/napari_mcp/widget.py 89.43% <66.66%> (+2.76%) ⬆️
src/napari_mcp/cli/install/codex_cli.py 60.21% <65.00%> (+5.57%) ⬆️
src/napari_mcp/cli/install/utils.py 93.05% <82.97%> (-0.41%) ⬇️
... and 6 more
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 14

🧹 Nitpick comments (18)
.github/workflows/release.yml (3)

67-68: Same condition improvement applies here.

For consistency and precision, consider using startsWith(github.ref, 'refs/tags/') instead of contains(github.ref, 'tags').

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.github/workflows/release.yml around lines 67 - 68, Update the workflow
conditional that currently uses contains(github.ref, 'tags') && !inputs.dry_run
to use startsWith(github.ref, 'refs/tags/') && !inputs.dry_run for precise tag
detection; locate the step using the softprops/action-gh-release@v2 entry and
replace the condition so it only triggers on refs that actually start with
refs/tags/.

85-89: Redundant directory listings in dry run notification.

Lines 87 and 89 both list the dist/ directory with slightly different formats (-la vs -lh). The "Wheel contents" label on line 88 is also misleading since ls -lh dist/ shows file metadata, not the contents of the wheel archive.

Suggested fix
     - name: Dry run notification
       if: inputs.dry_run
       run: |
         echo "🔍 Dry run complete - artifacts built but not published"
         echo "Built artifacts:"
         ls -la dist/
-        echo "Wheel contents:"
-        ls -lh dist/

Or if you want to actually show wheel contents:

-        echo "Wheel contents:"
-        ls -lh dist/
+        echo "Wheel contents:"
+        unzip -l dist/*.whl | head -50
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.github/workflows/release.yml around lines 85 - 89, Remove the redundant
directory listing by keeping a single informative listing and correct the
misleading "Wheel contents:" line: replace the duplicate 'ls -lh dist/' (or the
"Wheel contents:" echo followed by 'ls -lh dist/') with either a single 'ls -la
dist/' after the "Built artifacts:" echo, or if you intend to show actual wheel
archive contents, replace that block with a command that inspects wheel archives
(e.g., 'unzip -l dist/*.whl') and update the label accordingly; look for the
exact strings "🔍 Dry run complete - artifacts built but not published", "Built
artifacts:", "Wheel contents:", 'ls -la dist/' and 'ls -lh dist/' to make this
change.

48-59: Version extraction regex may miss pre-release versions.

The sed pattern 's/.*-\([0-9][0-9.]*\)-.*/\1/p' only matches numeric versions with dots. It won't correctly extract versions like 1.0.0rc1, 1.0.0a1, or 1.0.0.dev1 which are valid PEP 440 versions.

Additionally, consider using startsWith(github.ref, 'refs/tags/') instead of contains(github.ref, 'tags') for more precise tag detection—contains could theoretically match a branch named my-tags-branch.

Suggested improvements
     - name: Validate version matches tag
-      if: contains(github.ref, 'tags')
+      if: startsWith(github.ref, 'refs/tags/')
       run: |
         WHEEL=$(ls dist/*.whl | head -1)
-        WHEEL_VERSION=$(echo "$WHEEL" | sed -n 's/.*-\([0-9][0-9.]*\)-.*/\1/p')
+        WHEEL_VERSION=$(echo "$WHEEL" | sed -n 's/.*-\([0-9][0-9.a-zA-Z]*\)-.*/\1/p')
         TAG_VERSION="${GITHUB_REF_NAME#v}"
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.github/workflows/release.yml around lines 48 - 59, The workflow step
"Validate version matches tag" uses a narrow sed pattern to set WHEEL_VERSION
and a loose contains(github.ref, 'tags') check; update the tag detection to use
startsWith(github.ref, 'refs/tags/') and broaden the wheel-version extraction so
pre-release PEP 440 identifiers (e.g., a, b, rc, dev, plus signs, letters) are
captured into WHEEL_VERSION (replace the sed pattern that produces WHEEL_VERSION
with one that allows letters and punctuation used in PEP 440), then compare that
WHEEL_VERSION to TAG_VERSION (still derived from GITHUB_REF_NAME#v) as before.
Ensure you modify the "Validate version matches tag" step and the variables
WHEEL_VERSION and TAG_VERSION in the diff.
.github/workflows/test.yml (2)

177-199: Well-structured non-blocking security job.

The security job design is sound:

  • Job-level continue-on-error: true ensures pipeline completion
  • Step-level continue-on-error: true allows both scans to run even if one fails
  • Bandit exclusions (B110, B101) are reasonable for test/development code

One minor improvement: consider saving the security scan outputs as artifacts for later review.

Optional: Save security scan artifacts
     - name: Bandit scan
       continue-on-error: true
-      run: bandit -r src/ --skip B110,B101 -f json
+      run: bandit -r src/ --skip B110,B101 -f json -o bandit-report.json || true

     - name: Safety check
       continue-on-error: true
-      run: safety check
+      run: safety check --output json > safety-report.json || true
+
+    - name: Upload security reports
+      if: always()
+      uses: actions/upload-artifact@v4
+      with:
+        name: security-reports
+        path: |
+          bandit-report.json
+          safety-report.json
+        retention-days: 30
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.github/workflows/test.yml around lines 177 - 199, Add artifact upload steps
to the existing security job so scan outputs are preserved: capture Bandit JSON
by writing the bandit command output to a file (in the "Bandit scan" step) and
capture Safety output to a file (in the "Safety check" step), then add
subsequent steps that use actions/upload-artifact@v4 to upload those files;
reference the "security" job and the "Bandit scan" and "Safety check" steps when
implementing so the outputs (e.g., bandit-report.json and safety-report.txt) are
saved as artifacts for later review.

191-191: Safety version pin explanation would be helpful.

The "safety<4.0" pin suggests a breaking change or compatibility issue in Safety v4. Consider adding a comment explaining the reason for the pin, which helps future maintainers understand when it might be safe to upgrade.

Add explanatory comment
     - name: Install security tools
-      run: pip install bandit "safety<4.0"
+      # Safety 4.0+ requires authentication; pin to 3.x for open-source CI
+      run: pip install bandit "safety<4.0"
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.github/workflows/test.yml at line 191, Add a brief inline comment next to
the pip install step that pins Safety ("safety<4.0") explaining why v4 is
avoided (e.g., known breaking change, API/CLI differences, or compatibility
issue with our test tooling), reference the run: pip install bandit "safety<4.0"
line so reviewers can find it, and include guidance on what conditions or
release notes to look for before removing the pin (e.g., "remove when Safety v4
fixes X or our tooling supports new CLI").
CONTRIBUTING.md (1)

203-207: "Bad" example uses same function name add_layer as the "good" example above.

Both examples define add_layer, which may confuse readers. Consider using a different function name in the bad example (e.g., add_data or process_layer) to make the contrast clearer.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@CONTRIBUTING.md` around lines 203 - 207, The "Bad" example reuses the
function name add_layer which conflicts with the "Good" example; change the bad
example's function name (e.g., add_data or process_layer) so it doesn't reuse
add_layer and update any references within that snippet to the new name,
ensuring the examples clearly contrast without name collision.
docs/scripts/gen_ref_pages.py (2)

198-199: Hardcoded tool count "16" will drift out of sync with actual tools.

The tool count is hardcoded as "16" in multiple places while ALL_TOOLS is computed dynamically. This creates a maintenance burden where adding/removing tools requires updating these strings manually.

Consider deriving the count from ALL_TOOLS:

# Line 198-199
f"Auto-generated documentation for all {len(ALL_TOOLS)} napari MCP server tools, "
# Line 264
f"**Total: {len(ALL_TOOLS)} MCP tools available**"

Also applies to: 259-264

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/scripts/gen_ref_pages.py` around lines 198 - 199, Replace the hardcoded
"16" tool counts with dynamic length calls using the ALL_TOOLS collection:
update the f-strings that currently say "all 16 napari MCP server tools" and
"**Total: 16 MCP tools available**" to use f"...{len(ALL_TOOLS)}..." so the
displayed counts (around the blocks that build the page header and the
summary/footer) reflect the actual number of tools; ensure you reference the
same ALL_TOOLS variable used to generate the tool list so the count stays in
sync with functions that iterate over ALL_TOOLS.

82-84: Comment mentions @_register decorator but code doesn't actually check for it.

The docstring states it looks for functions "decorated with @_register", but the implementation only checks if child.name in ALL_TOOLS. This is fine if the intention is to match by name (which works), but the comment is misleading.

Consider either:

  1. Updating the docstring to reflect the actual behavior (matching by name)
  2. Or adding decorator verification if that's truly desired

Also applies to: 93-95

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/scripts/gen_ref_pages.py` around lines 82 - 84, The docstring for
create_server() incorrectly claims it looks for functions decorated with
`@_register` while the implementation actually matches by name using ALL_TOOLS
(child.name in ALL_TOOLS); update the docstring to state that the function
searches for async def <name>(...) inside create_server() that match names in
ALL_TOOLS (or clearly document the name-based matching), or alternatively
implement actual decorator verification by inspecting child.decorator_list for a
decorator named '_register' and requiring it in addition to name-matching
(referencing create_server(), ALL_TOOLS, and the `@_register` decorator).
src/napari_mcp/_helpers.py (1)

93-97: Silent exception swallowing may hide bugs in contrast_limits extraction.

The bare except Exception: pass silently ignores all errors. While this prevents crashes, it could hide legitimate bugs (e.g., unexpected data types). Consider logging at debug level or being more specific about expected exceptions.

♻️ Suggested improvement
         try:
             cl = layer.contrast_limits
             detail["contrast_limits"] = [float(cl[0]), float(cl[1])]
-        except Exception:
-            pass
+        except (TypeError, IndexError, AttributeError):
+            # contrast_limits may be None or have unexpected format
+            pass
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/napari_mcp/_helpers.py` around lines 93 - 97, The current try/except
around reading layer.contrast_limits silently swallows all exceptions; change it
to catch only expected exceptions (e.g., AttributeError, TypeError, IndexError)
when converting cl to floats and populate detail["contrast_limits"] from
contrast_limits, and add a debug-level log message including the offending value
(contrast_limits or cl) when an exception occurs so issues are visible during
debugging; update the block around contrast_limits in _helpers.py (the
variables/members: layer.contrast_limits, cl, detail["contrast_limits"]) to
implement specific exception handling and a debug log instead of a bare
except/pass.
src/napari_mcp/__init__.py (1)

21-26: Decouple guarded Qt imports to avoid coupled fallback behavior.

If one import fails, both symbols are set to None today. Splitting the try blocks keeps partial availability and makes failures easier to diagnose.

♻️ Suggested split
-try:
-    from .bridge_server import NapariBridgeServer
-    from .widget import MCPControlWidget
-except ImportError:  # pragma: no cover
-    NapariBridgeServer = None  # type: ignore[assignment,misc]
-    MCPControlWidget = None  # type: ignore[assignment,misc]
+try:
+    from .bridge_server import NapariBridgeServer
+except ImportError:  # pragma: no cover
+    NapariBridgeServer = None  # type: ignore[assignment,misc]
+
+try:
+    from .widget import MCPControlWidget
+except ImportError:  # pragma: no cover
+    MCPControlWidget = None  # type: ignore[assignment,misc]
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/napari_mcp/__init__.py` around lines 21 - 26, Split the combined
try/except into two independent try blocks so a failure importing one symbol
doesn't set both to None: wrap "from .bridge_server import NapariBridgeServer"
in its own try/except and set NapariBridgeServer = None with the same type
ignore and pragma as fallback, and do the same separately for "from .widget
import MCPControlWidget" so MCPControlWidget remains available when only the
other import fails; preserve the ImportError catch, comments (pragma: no cover),
and the type: ignore annotations for each fallback assignment.
tests/test_performance.py (1)

120-128: Reset global server state at the end of the exec-globals persistence test.

Line 121 sets shared viewer state but the test doesn’t restore it, so later tests may inherit mutated state.

♻️ Minimal teardown pattern
-        viewer = make_napari_viewer()
-        napari_mcp_server._state.viewer = viewer
-
-        res = await napari_mcp_server.execute_code("my_var = 42")
-        assert res["status"] == "ok"
-
-        res = await napari_mcp_server.execute_code("my_var * 2")
-        assert res["status"] == "ok"
-        assert res["result_repr"] == "84"
+        viewer = make_napari_viewer()
+        napari_mcp_server._state.viewer = viewer
+        try:
+            res = await napari_mcp_server.execute_code("my_var = 42")
+            assert res["status"] == "ok"
+
+            res = await napari_mcp_server.execute_code("my_var * 2")
+            assert res["status"] == "ok"
+            assert res["result_repr"] == "84"
+        finally:
+            napari_mcp_server._state.viewer = None
+            napari_mcp_server._state.exec_globals.clear()
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/test_performance.py` around lines 120 - 128, The test sets shared
viewer state (viewer via make_napari_viewer) on napari_mcp_server._state.viewer
but never restores it; update the test to reset that global state at the end
(teardown) by clearing napari_mcp_server._state.viewer (e.g., set to None) so
subsequent tests do not inherit mutated viewer state; locate the assignments to
viewer and napari_mcp_server._state.viewer in the test and add a final cleanup
step to restore the original value.
tests/test_bridge_server.py (1)

78-78: Accessing private FastMCP internals is fragile.

server._tool_manager._tools relies on FastMCP's internal implementation details. If FastMCP refactors its internals, this test will break silently.

Consider using the public get_tools() method (already used elsewhere in this file) for consistency and resilience:

tools = await server.server.get_tools()
tool_names = set(tools.keys())
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/test_bridge_server.py` at line 78, The test is accessing FastMCP
internals via server.server._tool_manager._tools which is fragile; replace that
direct access with the public API by calling await server.server.get_tools() and
derive tool_names from the returned mapping (e.g., tool_names =
set(tools.keys())) so the test uses server.server.get_tools() instead of
server.server._tool_manager._tools.
src/napari_mcp/qt_helpers.py (1)

41-56: Consider logging exceptions instead of silent pass.

The broad except: pass at line 55-56 may hide legitimate issues (e.g., viewer.window exists but _qt_window doesn't due to napari version changes).

🔧 Suggested improvement
     except Exception:
-        pass
+        import logging
+        logging.getLogger(__name__).debug(
+            "Could not connect window destroyed signal", exc_info=True
+        )

This preserves the defensive behavior while providing debuggability.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/napari_mcp/qt_helpers.py` around lines 41 - 56, The function
connect_window_destroyed_signal currently swallows all errors with a bare
except; replace the silent pass with logging of the caught exception so failures
retrieving viewer.window._qt_window or connecting qt_win.destroyed.connect(...)
are visible; catch Exception as e and call a module logger (e.g.,
logging.getLogger(__name__)) to log the exception (logger.exception or
logger.error with the exception) while preserving the existing behavior of not
raising and keeping state.window_close_connected unchanged on failure.
src/napari_mcp/cli/install/utils.py (1)

206-214: Consider preserving the original args structure when napari_requirement is provided.

The current implementation replaces the entire args list when napari_requirement is set. This works but duplicates the base structure. A minor improvement would be to insert the extra --with arguments.

♻️ Optional: Insert instead of replace
         if napari_requirement:
-            config["args"] = [
-                "run",
-                "--with",
-                "napari-mcp",
-                "--with",
-                napari_requirement,
-                "napari-mcp",
-            ]
+            # Insert additional --with before the final "napari-mcp"
+            config["args"] = [
+                "run",
+                "--with",
+                "napari-mcp",
+                "--with",
+                napari_requirement,
+                "napari-mcp",
+            ]

The current approach is fine and explicit—this is purely a stylistic observation.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/napari_mcp/cli/install/utils.py` around lines 206 - 214, The code
replaces config["args"] when napari_requirement is set, duplicating the base
args; instead update the existing config["args"] list by inserting the extra
"--with", napari_requirement pair into the current args (e.g., locate
config["args"] and call an insert/extend operation) so the original structure
(including existing "run" and "napari-mcp" entries) is preserved; modify the
block that checks napari_requirement to append or insert the two tokens rather
than reassigning config["args"].
src/napari_mcp/state.py (3)

120-129: Eviction logic has potential inefficiency with large storage.

The eviction sorts all keys on every insert that exceeds capacity. For typical usage this is fine, but with very large max_output_items values, this could become noticeable.

For the current use case (default 1000 items), this is acceptable. If performance becomes a concern, consider using collections.OrderedDict and popping from the front, or maintaining insertion order differently.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/napari_mcp/state.py` around lines 120 - 129, The eviction logic in the
method that trims output_storage when len(self.output_storage) >
self.max_output_items currently sorts all keys each time (see
self.output_storage, self.max_output_items), which is inefficient for large
stores; to fix, switch to a data structure that preserves insertion order such
as collections.OrderedDict (or Python 3.7+ dict guarantees insertion order) and
update the code that inserts/evicts so you can popitem(last=False) or pop the
oldest key without sorting; update any places that assign into
self.output_storage to use OrderedDict (or maintain a separate deque of keys)
and replace the sorted(...)[ :overflow] removal with repeated
popitem(last=False) (or deque.popleft) to remove exactly overflow oldest
entries.

170-171: Broad exception handling swallows all errors silently.

Catching Exception and returning None is appropriate for the fallback behavior, but consider logging at DEBUG level for troubleshooting.

🔧 Add debug logging for troubleshooting
         except Exception:
+            logger.debug("proxy_to_external failed", exc_info=True)
             return None
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/napari_mcp/state.py` around lines 170 - 171, The except Exception: return
None block in src/napari_mcp/state.py is swallowing errors silently; change it
to capture the exception (except Exception as e) and emit a debug-level log
before returning None—use the module logger (e.g., logger.debug) or
logging.getLogger(__name__).debug with a concise message and the exception info
(include the exception object or exc_info) so troubleshooting is possible while
preserving the fallback behavior in that function.

143-171: Add connection timeout to prevent hanging on unreachable services.

The fastmcp.Client supports a timeout parameter to prevent indefinite hangs when the external service is unreachable.

🔧 Add timeout parameter
         try:
             from fastmcp import Client

-            client = Client(f"http://localhost:{self.bridge_port}/mcp")
+            client = Client(
+                f"http://localhost:{self.bridge_port}/mcp",
+                timeout=5.0,
+            )
             async with client:
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/napari_mcp/state.py` around lines 143 - 171, The external MCP client
creation using fastmcp.Client currently can hang if the external service is
unreachable; update the client instantiation (where fastmcp.Client is
constructed with f"http://localhost:{self.bridge_port}/mcp") to pass a timeout
argument (e.g., timeout=<reasonable_seconds>) so connections fail fast, and
ensure any timeout-related exceptions are handled like other exceptions in the
surrounding try/except block; locate the code around the Client construction and
the async with client block and add the timeout param to the Client(...) call
(and adjust error handling if you want to surface timeout-specific info).
src/napari_mcp/bridge_server.py (1)

272-272: Consider documenting the 600s timeout choice.

The execute_code timeout is hardcoded to 600s (10 minutes), which differs from the general 300s (5 minutes) default in run_in_main_thread. A brief comment explaining this choice would help maintainability.

📝 Add clarifying comment
-            self.qt_bridge.run_in_main_thread(_run_on_qt, timeout=600.0)
+            # 10 minutes: longer than default 5min to allow heavier computations
+            self.qt_bridge.run_in_main_thread(_run_on_qt, timeout=600.0)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/napari_mcp/bridge_server.py` at line 272, The execute_code coroutine sets
a hardcoded 600s timeout which differs from the 300s default used by
run_in_main_thread; add a concise comment above async def execute_code (and/or
next to the timeout usage) explaining why a longer 10-minute timeout is required
(e.g., expected long-running user code, blocking image processing, or historical
reasons) and note that it intentionally diverges from run_in_main_thread’s 300s
default so future maintainers understand the rationale.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@docs/getting-started/quickstart.md`:
- Line 36: The fenced code block starting with "```bash" should be converted to
an indented code block to satisfy markdownlint MD046; locate the fenced block
marker (```bash) in quickstart.md and replace the fenced block by indenting each
line of the snippet with four spaces (or alternatively update the repo's
markdownlint config to allow fenced blocks), ensuring the code is formatted as
an indented code block rather than using ``` markers.

In `@docs/integrations/index.md`:
- Around line 24-25: The table rows use bare tokens ("cline-cursor" and "codex")
that are easy to mis-copy; replace those tokens with the full CLI commands to
match the first alternative. Specifically, in the row containing "Cline" change
the second column from `cline-cursor` to `napari-mcp-install install
cline-cursor`, and in the "Gemini / Codex" row change `codex` to
`napari-mcp-install install codex` (or `napari-mcp-install install gemini` if
you prefer that alternative), so both options are full, copy-pasteable commands.

In `@docs/integrations/python.md`:
- Line 171: Replace the invalid Anthropic model identifier used in the example
(the model="claude-sonnet-4-6" entry) with a valid model ID; update the string
in the docs/integrations/python.md snippet to a supported name such as
"claude-sonnet-4-20250514" or "claude-3-7-sonnet-20250219" so the sample will
work against the Anthropic API.

In `@mkdocs.yml`:
- Around line 114-118: The navigation entries in mkdocs.yml reference missing
docs: api/session.md, api/viewer-controls.md, and api/modules.md; fix by either
creating the three files under docs/api (session.md, viewer-controls.md,
modules.md) with appropriate content or update the mkdocs.yml nav entries (the
lines listing "Session & Viewer: api/session.md", "Viewer Controls:
api/viewer-controls.md", "Supporting Modules: api/modules.md") to point to
existing documentation paths or remove them; ensure the referenced filenames
exactly match the created files' names and that the docs/api directory contains
those files so MkDocs validation passes.

In `@pyproject.toml`:
- Around line 186-190: Remove the file-wide S102/S307 suppressions from
server.py and bridge_server.py and remove the S603/S607 suppression from
codex_cli.py; keep the A002 suppression in server.py. Instead, add inline noqa
comments on the specific risky callsites: in _helpers.py add "# noqa: S102,
S307" directly on the lines with the exec/eval calls inside the run_code/execute
helpers (the three exec/eval occurrences), and in cli/install/utils.py add "#
noqa: S603, S607" on the single subprocess call used for Python detection; this
preserves intent while avoiding broad file-level silences.
- Line 55: The `all` extra under [project.optional-dependencies] currently only
lists "napari-mcp[dev]" which omits the `[dependency-groups]` testing
dependencies, causing pip installs of napari-mcp[all] to miss test tools; update
the `all` extra to include the testing group packages (or merge the testing
group into `all`) so that `all` contains the same test-related entries defined
under `testing` in `[dependency-groups]` (or alternatively add a short comment
documenting the intentional divergence between `[project.optional-dependencies]
all` and `[dependency-groups] testing` if you prefer to keep them separate).

In `@src/napari_mcp/_helpers.py`:
- Around line 223-232: Validate that resolved_data is a sequence with at least
two elements and that resolved_data[0] and resolved_data[1] are array-like
before indexing; in the branch that calls viewer.add_surface and assigns verts =
np.asarray(resolved_data[0]) and faces = np.asarray(resolved_data[1]), add a
guard that checks isinstance(resolved_data, (list, tuple)) and
len(resolved_data) >= 2 (and optionally verify verts.ndim == 2 and faces.ndim ==
2 with appropriate shapes), and if the check fails return or raise a clear
ValueError/error dict indicating an invalid surface tuple instead of allowing an
IndexError from verts/faces access.
- Around line 214-221: In the elif lt == "tracks" branch validate the numpy
array before indexing: ensure resolved_data -> arr is a 2D array (arr.ndim == 2)
and has at least one column (arr.shape[1] >= 1); if validation fails return a
clear error status/message instead of proceeding to viewer.add_tracks and
computing n_tracks via arr[:,0]; update the block around the arr creation and
the n_tracks calculation to perform these checks and handle invalid shapes
gracefully (refer to variables arr, viewer.add_tracks, and the n_tracks
calculation using arr[:, 0]).

In `@src/napari_mcp/cli/main.py`:
- Around line 341-344: In list_installations, reading each config with
read_json_config can raise for corrupted files; wrap the call to
read_json_config(config_path) in a try/except that catches the propagated
exceptions, log or warn about the corrupted config (including config_path and
error) and continue to the next item, then only call
check_existing_server(config, "napari-mcp") when config was successfully loaded
to avoid aborting the entire listing.

In `@src/napari_mcp/qt_helpers.py`:
- Around line 14-27: The current check "if QtWidgets is None" never runs because
qtpy raises on failed imports; change the top import of QtWidgets to a
try/except that catches ImportError (and optionally QtBindingsNotFoundError) and
sets QtWidgets = None on failure, then keep the existing fallback that defines
_StubApp and assigns state.qt_app = _StubApp() when state.qt_app is None; ensure
references to QtWidgets, _StubApp and state.qt_app are used exactly as in the
diff so the stub is used in headless environments.

In `@src/napari_mcp/state.py`:
- Around line 208-240: The external_session_information method lacks exception
handling around the external Client usage (unlike detect_external_viewer and
proxy_to_external); wrap the Client creation and async with /
test_client.call_tool block in a try/except, catch broad exceptions, log or
record the exception (including the exception message and optionally traceback)
and return the existing error dict with a helpful message (including the
exception text) instead of letting the exception propagate; reference
external_session_information, Client, test_client.call_tool and self.bridge_port
when locating and updating the code.

In `@tests/test_helpers.py`:
- Around line 449-460: Update the type hint for the line_limit parameter to
include None so it matches the function's runtime behavior and the test; locate
the function signature (e.g., build_truncated_response or the helper in
_helpers.py where line_limit is declared as line_limit: int | str) and change
the annotation to line_limit: int | str | None (or Optional[int | str]) to
reflect that None is accepted and will fallback to 30.

In `@tests/test_property_based.py`:
- Around line 66-70: The test's line-counting logic for variable result doesn't
match truncate_output's behavior; change the computation of actual_line_count to
mirror truncate_output by using result.splitlines(keepends=True) (and handle
result being falsy as before) so actual_line_count =
len(result.splitlines(keepends=True)) — update the assertion accordingly;
reference variables: actual_line_count, result and function truncate_output to
locate the code to change.

In `@tests/test_qt_helpers.py`:
- Around line 138-139: The test currently only asserts
mock_shutdown.assert_called_once() inside an if mock_shutdown.called guard so it
can silently pass if the Qt destroyed signal never fires; change the test to be
deterministic by explicitly emitting or invoking the cleanup path (e.g., trigger
the widget's destroyed signal or call the method that should run shutdown) and
then assert mock_shutdown.assert_called_once() unconditionally, or if you cannot
reliably trigger the signal make the test skip (pytest.skip) instead of silently
passing; locate the assertion around mock_shutdown and the code that
connects/emits the Qt destroyed signal to implement this change.

---

Nitpick comments:
In @.github/workflows/release.yml:
- Around line 67-68: Update the workflow conditional that currently uses
contains(github.ref, 'tags') && !inputs.dry_run to use startsWith(github.ref,
'refs/tags/') && !inputs.dry_run for precise tag detection; locate the step
using the softprops/action-gh-release@v2 entry and replace the condition so it
only triggers on refs that actually start with refs/tags/.
- Around line 85-89: Remove the redundant directory listing by keeping a single
informative listing and correct the misleading "Wheel contents:" line: replace
the duplicate 'ls -lh dist/' (or the "Wheel contents:" echo followed by 'ls -lh
dist/') with either a single 'ls -la dist/' after the "Built artifacts:" echo,
or if you intend to show actual wheel archive contents, replace that block with
a command that inspects wheel archives (e.g., 'unzip -l dist/*.whl') and update
the label accordingly; look for the exact strings "🔍 Dry run complete -
artifacts built but not published", "Built artifacts:", "Wheel contents:", 'ls
-la dist/' and 'ls -lh dist/' to make this change.
- Around line 48-59: The workflow step "Validate version matches tag" uses a
narrow sed pattern to set WHEEL_VERSION and a loose contains(github.ref, 'tags')
check; update the tag detection to use startsWith(github.ref, 'refs/tags/') and
broaden the wheel-version extraction so pre-release PEP 440 identifiers (e.g.,
a, b, rc, dev, plus signs, letters) are captured into WHEEL_VERSION (replace the
sed pattern that produces WHEEL_VERSION with one that allows letters and
punctuation used in PEP 440), then compare that WHEEL_VERSION to TAG_VERSION
(still derived from GITHUB_REF_NAME#v) as before. Ensure you modify the
"Validate version matches tag" step and the variables WHEEL_VERSION and
TAG_VERSION in the diff.

In @.github/workflows/test.yml:
- Around line 177-199: Add artifact upload steps to the existing security job so
scan outputs are preserved: capture Bandit JSON by writing the bandit command
output to a file (in the "Bandit scan" step) and capture Safety output to a file
(in the "Safety check" step), then add subsequent steps that use
actions/upload-artifact@v4 to upload those files; reference the "security" job
and the "Bandit scan" and "Safety check" steps when implementing so the outputs
(e.g., bandit-report.json and safety-report.txt) are saved as artifacts for
later review.
- Line 191: Add a brief inline comment next to the pip install step that pins
Safety ("safety<4.0") explaining why v4 is avoided (e.g., known breaking change,
API/CLI differences, or compatibility issue with our test tooling), reference
the run: pip install bandit "safety<4.0" line so reviewers can find it, and
include guidance on what conditions or release notes to look for before removing
the pin (e.g., "remove when Safety v4 fixes X or our tooling supports new CLI").

In `@CONTRIBUTING.md`:
- Around line 203-207: The "Bad" example reuses the function name add_layer
which conflicts with the "Good" example; change the bad example's function name
(e.g., add_data or process_layer) so it doesn't reuse add_layer and update any
references within that snippet to the new name, ensuring the examples clearly
contrast without name collision.

In `@docs/scripts/gen_ref_pages.py`:
- Around line 198-199: Replace the hardcoded "16" tool counts with dynamic
length calls using the ALL_TOOLS collection: update the f-strings that currently
say "all 16 napari MCP server tools" and "**Total: 16 MCP tools available**" to
use f"...{len(ALL_TOOLS)}..." so the displayed counts (around the blocks that
build the page header and the summary/footer) reflect the actual number of
tools; ensure you reference the same ALL_TOOLS variable used to generate the
tool list so the count stays in sync with functions that iterate over ALL_TOOLS.
- Around line 82-84: The docstring for create_server() incorrectly claims it
looks for functions decorated with `@_register` while the implementation actually
matches by name using ALL_TOOLS (child.name in ALL_TOOLS); update the docstring
to state that the function searches for async def <name>(...) inside
create_server() that match names in ALL_TOOLS (or clearly document the
name-based matching), or alternatively implement actual decorator verification
by inspecting child.decorator_list for a decorator named '_register' and
requiring it in addition to name-matching (referencing create_server(),
ALL_TOOLS, and the `@_register` decorator).

In `@src/napari_mcp/__init__.py`:
- Around line 21-26: Split the combined try/except into two independent try
blocks so a failure importing one symbol doesn't set both to None: wrap "from
.bridge_server import NapariBridgeServer" in its own try/except and set
NapariBridgeServer = None with the same type ignore and pragma as fallback, and
do the same separately for "from .widget import MCPControlWidget" so
MCPControlWidget remains available when only the other import fails; preserve
the ImportError catch, comments (pragma: no cover), and the type: ignore
annotations for each fallback assignment.

In `@src/napari_mcp/_helpers.py`:
- Around line 93-97: The current try/except around reading layer.contrast_limits
silently swallows all exceptions; change it to catch only expected exceptions
(e.g., AttributeError, TypeError, IndexError) when converting cl to floats and
populate detail["contrast_limits"] from contrast_limits, and add a debug-level
log message including the offending value (contrast_limits or cl) when an
exception occurs so issues are visible during debugging; update the block around
contrast_limits in _helpers.py (the variables/members: layer.contrast_limits,
cl, detail["contrast_limits"]) to implement specific exception handling and a
debug log instead of a bare except/pass.

In `@src/napari_mcp/bridge_server.py`:
- Line 272: The execute_code coroutine sets a hardcoded 600s timeout which
differs from the 300s default used by run_in_main_thread; add a concise comment
above async def execute_code (and/or next to the timeout usage) explaining why a
longer 10-minute timeout is required (e.g., expected long-running user code,
blocking image processing, or historical reasons) and note that it intentionally
diverges from run_in_main_thread’s 300s default so future maintainers understand
the rationale.

In `@src/napari_mcp/cli/install/utils.py`:
- Around line 206-214: The code replaces config["args"] when napari_requirement
is set, duplicating the base args; instead update the existing config["args"]
list by inserting the extra "--with", napari_requirement pair into the current
args (e.g., locate config["args"] and call an insert/extend operation) so the
original structure (including existing "run" and "napari-mcp" entries) is
preserved; modify the block that checks napari_requirement to append or insert
the two tokens rather than reassigning config["args"].

In `@src/napari_mcp/qt_helpers.py`:
- Around line 41-56: The function connect_window_destroyed_signal currently
swallows all errors with a bare except; replace the silent pass with logging of
the caught exception so failures retrieving viewer.window._qt_window or
connecting qt_win.destroyed.connect(...) are visible; catch Exception as e and
call a module logger (e.g., logging.getLogger(__name__)) to log the exception
(logger.exception or logger.error with the exception) while preserving the
existing behavior of not raising and keeping state.window_close_connected
unchanged on failure.

In `@src/napari_mcp/state.py`:
- Around line 120-129: The eviction logic in the method that trims
output_storage when len(self.output_storage) > self.max_output_items currently
sorts all keys each time (see self.output_storage, self.max_output_items), which
is inefficient for large stores; to fix, switch to a data structure that
preserves insertion order such as collections.OrderedDict (or Python 3.7+ dict
guarantees insertion order) and update the code that inserts/evicts so you can
popitem(last=False) or pop the oldest key without sorting; update any places
that assign into self.output_storage to use OrderedDict (or maintain a separate
deque of keys) and replace the sorted(...)[ :overflow] removal with repeated
popitem(last=False) (or deque.popleft) to remove exactly overflow oldest
entries.
- Around line 170-171: The except Exception: return None block in
src/napari_mcp/state.py is swallowing errors silently; change it to capture the
exception (except Exception as e) and emit a debug-level log before returning
None—use the module logger (e.g., logger.debug) or
logging.getLogger(__name__).debug with a concise message and the exception info
(include the exception object or exc_info) so troubleshooting is possible while
preserving the fallback behavior in that function.
- Around line 143-171: The external MCP client creation using fastmcp.Client
currently can hang if the external service is unreachable; update the client
instantiation (where fastmcp.Client is constructed with
f"http://localhost:{self.bridge_port}/mcp") to pass a timeout argument (e.g.,
timeout=<reasonable_seconds>) so connections fail fast, and ensure any
timeout-related exceptions are handled like other exceptions in the surrounding
try/except block; locate the code around the Client construction and the async
with client block and add the timeout param to the Client(...) call (and adjust
error handling if you want to surface timeout-specific info).

In `@tests/test_bridge_server.py`:
- Line 78: The test is accessing FastMCP internals via
server.server._tool_manager._tools which is fragile; replace that direct access
with the public API by calling await server.server.get_tools() and derive
tool_names from the returned mapping (e.g., tool_names = set(tools.keys())) so
the test uses server.server.get_tools() instead of
server.server._tool_manager._tools.

In `@tests/test_performance.py`:
- Around line 120-128: The test sets shared viewer state (viewer via
make_napari_viewer) on napari_mcp_server._state.viewer but never restores it;
update the test to reset that global state at the end (teardown) by clearing
napari_mcp_server._state.viewer (e.g., set to None) so subsequent tests do not
inherit mutated viewer state; locate the assignments to viewer and
napari_mcp_server._state.viewer in the test and add a final cleanup step to
restore the original value.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 74e57ab7-9162-45fa-b7d5-821963503c56

📥 Commits

Reviewing files that changed from the base of the PR and between 7743c8a and 4a9369f.

⛔ Files ignored due to path filters (1)
  • uv.lock is excluded by !**/*.lock
📒 Files selected for processing (69)
  • .github/workflows/claude-code-review.yml
  • .github/workflows/docs.yml
  • .github/workflows/release.yml
  • .github/workflows/test.yml
  • CONTRIBUTING.md
  • MANIFEST.in
  • README.md
  • codecov.yml
  • docs/examples/README.md
  • docs/examples/anthropic_integration.py
  • docs/getting-started/index.md
  • docs/getting-started/installation.md
  • docs/getting-started/quickstart.md
  • docs/getting-started/zero-install.md
  • docs/guides/napari-plugin.md
  • docs/guides/troubleshooting.md
  • docs/index.md
  • docs/integrations/chatgpt.md
  • docs/integrations/claude-code.md
  • docs/integrations/claude-desktop.md
  • docs/integrations/cline.md
  • docs/integrations/cursor.md
  • docs/integrations/index.md
  • docs/integrations/other-llms.md
  • docs/integrations/python.md
  • docs/scripts/gen_ref_pages.py
  • mkdocs.yml
  • pyproject.toml
  • src/napari_mcp/__init__.py
  • src/napari_mcp/_helpers.py
  • src/napari_mcp/bridge_server.py
  • src/napari_mcp/cli/install/base.py
  • src/napari_mcp/cli/install/cline_cursor.py
  • src/napari_mcp/cli/install/codex_cli.py
  • src/napari_mcp/cli/install/cursor.py
  • src/napari_mcp/cli/install/gemini_cli.py
  • src/napari_mcp/cli/install/utils.py
  • src/napari_mcp/cli/main.py
  • src/napari_mcp/output.py
  • src/napari_mcp/qt_helpers.py
  • src/napari_mcp/server.py
  • src/napari_mcp/state.py
  • src/napari_mcp/viewer_protocol.py
  • src/napari_mcp/widget.py
  • tests/conftest.py
  • tests/fixtures/__init__.py
  • tests/fixtures/async_utils.py
  • tests/test_base_installer.py
  • tests/test_bridge_server.py
  • tests/test_bridge_simplified.py
  • tests/test_cli_installer.py
  • tests/test_cli_installers/test_platform_installers.py
  • tests/test_cli_utils.py
  • tests/test_coverage.py
  • tests/test_edge_cases.py
  • tests/test_external_viewer.py
  • tests/test_helpers.py
  • tests/test_integration.py
  • tests/test_napari_server_coverage.py
  • tests/test_output_storage.py
  • tests/test_performance.py
  • tests/test_property_based.py
  • tests/test_qt_helpers.py
  • tests/test_server_tools.py
  • tests/test_state.py
  • tests/test_timelapse.py
  • tests/test_tools.py
  • tests/test_widget.py
  • tox.ini
💤 Files with no reviewable changes (8)
  • MANIFEST.in
  • tox.ini
  • tests/test_bridge_simplified.py
  • tests/fixtures/init.py
  • tests/test_napari_server_coverage.py
  • tests/fixtures/async_utils.py
  • tests/test_edge_cases.py
  • tests/test_coverage.py


### For Other Applications

```bash
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Fix markdownlint MD046 in this section.

Line 36 uses fenced code-block style where markdownlint expects indented style, so this will keep doc lint noisy/failing.

🧰 Tools
🪛 markdownlint-cli2 (0.21.0)

[warning] 36-36: Code block style
Expected: indented; Actual: fenced

(MD046, code-block-style)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/getting-started/quickstart.md` at line 36, The fenced code block
starting with "```bash" should be converted to an indented code block to satisfy
markdownlint MD046; locate the fenced block marker (```bash) in quickstart.md
and replace the fenced block by indenting each line of the snippet with four
spaces (or alternatively update the repo's markdownlint config to allow fenced
blocks), ensuring the code is formatted as an indented code block rather than
using ``` markers.

Comment on lines +24 to +25
| **Cline** | `napari-mcp-install install cline-vscode` or `cline-cursor` | ✅ Full Support | [Setup →](cline.md) |
| **Gemini / Codex** | `napari-mcp-install install gemini` or `codex` | ✅ Full Support | [Setup →](other-llms.md) |
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Use full CLI command for both alternatives in the table.

On Lines 24-25, the second option in each row is a bare token (cline-cursor, codex) instead of a full command, which is easy to misread/copy incorrectly.

Suggested doc fix
-| **Cline** | `napari-mcp-install install cline-vscode` or `cline-cursor` | ✅ Full Support | [Setup →](cline.md) |
-| **Gemini / Codex** | `napari-mcp-install install gemini` or `codex` | ✅ Full Support | [Setup →](other-llms.md) |
+| **Cline** | `napari-mcp-install install cline-vscode` or `napari-mcp-install install cline-cursor` | ✅ Full Support | [Setup →](cline.md) |
+| **Gemini / Codex** | `napari-mcp-install install gemini` or `napari-mcp-install install codex` | ✅ Full Support | [Setup →](other-llms.md) |
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
| **Cline** | `napari-mcp-install install cline-vscode` or `cline-cursor` | ✅ Full Support | [Setup →](cline.md) |
| **Gemini / Codex** | `napari-mcp-install install gemini` or `codex` | ✅ Full Support | [Setup →](other-llms.md) |
| **Cline** | `napari-mcp-install install cline-vscode` or `napari-mcp-install install cline-cursor` | ✅ Full Support | [Setup →](cline.md) |
| **Gemini / Codex** | `napari-mcp-install install gemini` or `napari-mcp-install install codex` | ✅ Full Support | [Setup →](other-llms.md) |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/integrations/index.md` around lines 24 - 25, The table rows use bare
tokens ("cline-cursor" and "codex") that are easy to mis-copy; replace those
tokens with the full CLI commands to match the first alternative. Specifically,
in the row containing "Cline" change the second column from `cline-cursor` to
`napari-mcp-install install cline-cursor`, and in the "Gemini / Codex" row
change `codex` to `napari-mcp-install install codex` (or `napari-mcp-install
install gemini` if you prefer that alternative), so both options are full,
copy-pasteable commands.

# Use Claude to interact with napari
message = client.messages.create(
model="claude-3-5-sonnet-20241022",
model="claude-sonnet-4-6",
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🌐 Web query:

Anthropic Claude model names 2025

💡 Result:

Anthropic Claude model names you’d commonly see in 2025 (product name → Anthropic API model ID):

  • Claude 3.7 Sonnetclaude-3-7-sonnet-20250219 (released Feb 24, 2025) [1][2]
  • Claude Sonnet 4claude-sonnet-4-20250514 [2]
  • Claude Opus 4claude-opus-4-20250514 [2]

Models also widely used during 2025 (though their snapshot dates are 2024):

  • Claude Sonnet 3.5claude-3-5-sonnet-20240620, claude-3-5-sonnet-20241022 [3]
  • Claude Haiku 3.5claude-3-5-haiku-20241022 [2][3]

Sources: [1] Anthropic announcement for Claude 3.7 Sonnet, [2] Anthropic “Model deprecations” list (includes official API model IDs), [3] Anthropic API release notes (lists 3.5 Sonnet model IDs and deprecation info.


Use a valid Anthropic model name instead of claude-sonnet-4-6.

The model name claude-sonnet-4-6 is invalid. Anthropic model IDs follow a specific format with a date suffix (e.g., claude-sonnet-4-20250514). Use claude-sonnet-4-20250514 (Claude Sonnet 4) or claude-3-7-sonnet-20250219 (Claude 3.7 Sonnet) instead. The current invalid name will cause API errors when users run this example.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/integrations/python.md` at line 171, Replace the invalid Anthropic model
identifier used in the example (the model="claude-sonnet-4-6" entry) with a
valid model ID; update the string in the docs/integrations/python.md snippet to
a supported name such as "claude-sonnet-4-20250514" or
"claude-3-7-sonnet-20250219" so the sample will work against the Anthropic API.

Comment thread mkdocs.yml
Comment on lines +114 to +118
- Session & Viewer: api/session.md
- Layer Management: api/layer-management.md
- Viewer Controls: api/viewer-controls.md
- Utilities: api/utilities.md
- Supporting Modules: api/modules.md
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Checking MkDocs nav targets added/updated in mkdocs.yml..."
for rel in docs/api/session.md docs/api/viewer-controls.md docs/api/modules.md; do
  if [ -f "$rel" ]; then
    echo "FOUND: $rel"
  else
    echo "MISSING: $rel"
  fi
done

echo
echo "Nav entries in mkdocs.yml:"
rg -n 'Session & Viewer|Viewer Controls|Supporting Modules' mkdocs.yml

Repository: royerlab/napari-mcp

Length of output: 388


All three referenced API documentation files are missing and will break the docs build.

Lines 114, 116, and 118 reference files that do not exist:

  • docs/api/session.md (referenced as api/session.md)
  • docs/api/viewer-controls.md (referenced as api/viewer-controls.md)
  • docs/api/modules.md (referenced as api/modules.md)

MkDocs will fail validation and the build will fail when it tries to resolve these navigation targets. Create these files or update the navigation entries in mkdocs.yml to point to existing documentation.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@mkdocs.yml` around lines 114 - 118, The navigation entries in mkdocs.yml
reference missing docs: api/session.md, api/viewer-controls.md, and
api/modules.md; fix by either creating the three files under docs/api
(session.md, viewer-controls.md, modules.md) with appropriate content or update
the mkdocs.yml nav entries (the lines listing "Session & Viewer:
api/session.md", "Viewer Controls: api/viewer-controls.md", "Supporting Modules:
api/modules.md") to point to existing documentation paths or remove them; ensure
the referenced filenames exactly match the created files' names and that the
docs/api directory contains those files so MkDocs validation passes.

Comment thread pyproject.toml
"black>=24.0.0",
]
all = ["napari-mcp[test,dev]"]
all = ["napari-mcp[dev]"]
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify whether repo docs/workflows expect `.[all]` to include test tooling.
rg -n -C2 '\.\[all\]|napari-mcp\[all\]|napari-mcp\[dev\]|optional-dependencies|dependency-groups'

Repository: royerlab/napari-mcp

Length of output: 1940


🏁 Script executed:

# Check if there are any references to other extras like 'test', 'docs', 'full' in the repo
rg -i 'extras.*=|dependencies.*\[test\]|\[docs\]|\[full\]|dev.*test|test.*dev' --type toml --type yaml --type md

Repository: royerlab/napari-mcp

Length of output: 672


🏁 Script executed:

# Check README or documentation for guidance on extras
fd -e md -e rst -e txt | head -20 | xargs grep -l "extra\|optional\|install" 2>/dev/null || echo "No match found"

Repository: royerlab/napari-mcp

Length of output: 586


🏁 Script executed:

# Read the full optional-dependencies and dependency-groups sections
sed -n '46,60p' pyproject.toml
echo "---"
sed -n '195,215p' pyproject.toml

Repository: royerlab/napari-mcp

Length of output: 839


🏁 Script executed:

# Get a wider view of the optional-dependencies section to see all extras
sed -n '46,75p' pyproject.toml

Repository: royerlab/napari-mcp

Length of output: 743


🏁 Script executed:

# Also get the full dependency-groups section to see what all includes
sed -n '198,230p' pyproject.toml

Repository: royerlab/napari-mcp

Length of output: 875


[project.optional-dependencies] all extra is incomplete and inconsistent with other package managers.

Line 55 defines all = ["napari-mcp[dev]"], but a separate testing group with pytest and related tools is defined in [dependency-groups]. This causes pip install napari-mcp[all] to exclude testing dependencies, while uv add napari-mcp[all] and pixi include them via [dependency-groups] and [tool.pixi.environments].

Either add testing dependencies to the pip all extra for consistency, or document the intentional split across package managers.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pyproject.toml` at line 55, The `all` extra under
[project.optional-dependencies] currently only lists "napari-mcp[dev]" which
omits the `[dependency-groups]` testing dependencies, causing pip installs of
napari-mcp[all] to miss test tools; update the `all` extra to include the
testing group packages (or merge the testing group into `all`) so that `all`
contains the same test-related entries defined under `testing` in
`[dependency-groups]` (or alternatively add a short comment documenting the
intentional divergence between `[project.optional-dependencies] all` and
`[dependency-groups] testing` if you prefer to keep them separate).

Comment on lines +14 to +27
from qtpy import QtWidgets

if QtWidgets is None:

class _StubApp:
def processEvents(self, *_: Any) -> None: # noqa: N802
pass

def setQuitOnLastWindowClosed(self, *_: Any) -> None: # noqa: N802
pass

if state.qt_app is None:
state.qt_app = _StubApp()
return state.qt_app
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

Does qtpy QtWidgets import return None when Qt is not available?

💡 Result:

No. If no Qt Python bindings (PyQt5/PySide2/PyQt6/PySide6) are available, importing qtpy (and thus from qtpy import QtWidgets) raises qtpy.QtBindingsNotFoundError (an ImportError), with the message “No Qt bindings could be found”—it does not return None. [1]

If a binding is selected but a particular submodule isn’t available, QtPy raises a (different) module-not-found exception (QtModuleNotFoundError / ModuleNotFoundError), rather than returning None. [1]

Sources:
[1] QtPy source (qtpy/__init__.py) showing QtBindingsNotFoundError and raise QtBindingsNotFoundError when bindings can’t be imported. (github.com)

Citations:


Fix the QtWidgets is None check—it will never be true.

When qtpy.QtWidgets cannot be imported, qtpy raises ImportError (specifically QtBindingsNotFoundError), not returns None. Without catching this exception, the code will crash instead of falling back to the stub implementation.

Wrap the import in a try/except block to properly handle headless scenarios:

Suggested fix
 def ensure_qt_app(state: ServerState) -> Any:
     """Return the Qt application, creating one if necessary, or a no-op stub."""
-    from qtpy import QtWidgets
-
-    if QtWidgets is None:
+    try:
+        from qtpy import QtWidgets
+    except ImportError:
+        QtWidgets = None
 
+    if QtWidgets is None:
         class _StubApp:
             def processEvents(self, *_: Any) -> None:  # noqa: N802
                 pass

             def setQuitOnLastWindowClosed(self, *_: Any) -> None:  # noqa: N802
                 pass

         if state.qt_app is None:
             state.qt_app = _StubApp()
         return state.qt_app
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
from qtpy import QtWidgets
if QtWidgets is None:
class _StubApp:
def processEvents(self, *_: Any) -> None: # noqa: N802
pass
def setQuitOnLastWindowClosed(self, *_: Any) -> None: # noqa: N802
pass
if state.qt_app is None:
state.qt_app = _StubApp()
return state.qt_app
try:
from qtpy import QtWidgets
except ImportError:
QtWidgets = None
if QtWidgets is None:
class _StubApp:
def processEvents(self, *_: Any) -> None: # noqa: N802
pass
def setQuitOnLastWindowClosed(self, *_: Any) -> None: # noqa: N802
pass
if state.qt_app is None:
state.qt_app = _StubApp()
return state.qt_app
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/napari_mcp/qt_helpers.py` around lines 14 - 27, The current check "if
QtWidgets is None" never runs because qtpy raises on failed imports; change the
top import of QtWidgets to a try/except that catches ImportError (and optionally
QtBindingsNotFoundError) and sets QtWidgets = None on failure, then keep the
existing fallback that defines _StubApp and assigns state.qt_app = _StubApp()
when state.qt_app is None; ensure references to QtWidgets, _StubApp and
state.qt_app are used exactly as in the diff so the stub is used in headless
environments.

Comment thread src/napari_mcp/state.py
Comment on lines +208 to +240
async def external_session_information(self) -> dict[str, Any]:
"""Get session information from the external viewer."""
from fastmcp import Client

test_client = Client(f"http://localhost:{self.bridge_port}/mcp")
async with test_client:
result = await test_client.call_tool("session_information")
if hasattr(result, "content"):
content = result.content
if isinstance(content, list) and len(content) > 0:
info = (
content[0].text
if hasattr(content[0], "text")
else str(content[0])
)
info_dict = json.loads(info) if isinstance(info, str) else info
if info_dict.get("session_type") == "napari_bridge_session":
return {
"status": "ok",
"viewer_type": "external",
"title": info_dict.get("viewer", {}).get(
"title", "External Viewer"
),
"layers": info_dict.get("viewer", {}).get(
"layer_names", []
),
"port": info_dict.get("bridge_port", self.bridge_port),
}

return {
"status": "error",
"message": "Failed to get session information from external viewer",
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Missing error handling for external_session_information.

Unlike detect_external_viewer and proxy_to_external, this method doesn't have a try/except block. If the client connection fails or call_tool raises, the exception will propagate uncaught.

🛡️ Proposed fix: Add try/except for consistency
     async def external_session_information(self) -> dict[str, Any]:
         """Get session information from the external viewer."""
-        from fastmcp import Client
+        try:
+            from fastmcp import Client

-        test_client = Client(f"http://localhost:{self.bridge_port}/mcp")
-        async with test_client:
-            result = await test_client.call_tool("session_information")
-            if hasattr(result, "content"):
-                content = result.content
-                if isinstance(content, list) and len(content) > 0:
-                    info = (
-                        content[0].text
-                        if hasattr(content[0], "text")
-                        else str(content[0])
-                    )
-                    info_dict = json.loads(info) if isinstance(info, str) else info
-                    if info_dict.get("session_type") == "napari_bridge_session":
-                        return {
-                            "status": "ok",
-                            "viewer_type": "external",
-                            "title": info_dict.get("viewer", {}).get(
-                                "title", "External Viewer"
-                            ),
-                            "layers": info_dict.get("viewer", {}).get(
-                                "layer_names", []
-                            ),
-                            "port": info_dict.get("bridge_port", self.bridge_port),
-                        }
+            test_client = Client(f"http://localhost:{self.bridge_port}/mcp")
+            async with test_client:
+                result = await test_client.call_tool("session_information")
+                if hasattr(result, "content"):
+                    content = result.content
+                    if isinstance(content, list) and len(content) > 0:
+                        info = (
+                            content[0].text
+                            if hasattr(content[0], "text")
+                            else str(content[0])
+                        )
+                        info_dict = json.loads(info) if isinstance(info, str) else info
+                        if info_dict.get("session_type") == "napari_bridge_session":
+                            return {
+                                "status": "ok",
+                                "viewer_type": "external",
+                                "title": info_dict.get("viewer", {}).get(
+                                    "title", "External Viewer"
+                                ),
+                                "layers": info_dict.get("viewer", {}).get(
+                                    "layer_names", []
+                                ),
+                                "port": info_dict.get("bridge_port", self.bridge_port),
+                            }
+        except Exception:
+            pass

         return {
             "status": "error",
             "message": "Failed to get session information from external viewer",
         }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/napari_mcp/state.py` around lines 208 - 240, The
external_session_information method lacks exception handling around the external
Client usage (unlike detect_external_viewer and proxy_to_external); wrap the
Client creation and async with / test_client.call_tool block in a try/except,
catch broad exceptions, log or record the exception (including the exception
message and optionally traceback) and return the existing error dict with a
helpful message (including the exception text) instead of letting the exception
propagate; reference external_session_information, Client, test_client.call_tool
and self.bridge_port when locating and updating the code.

Comment thread tests/test_helpers.py
Comment on lines +449 to +460
def test_none_line_limit_falls_back(self):
"""None line_limit should fall back to 30, not crash."""
resp = build_truncated_response(
status="ok",
output_id="id1",
stdout_full="hello\n",
stderr_full="",
result_repr=None,
line_limit=None,
)
assert resp["status"] == "ok"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check the type hint for line_limit in build_truncated_response
rg -n "def build_truncated_response" --type py -A 15

Repository: royerlab/napari-mcp

Length of output: 1002


🏁 Script executed:

#!/bin/bash
# Get the full implementation of build_truncated_response
sed -n '325,380p' src/napari_mcp/_helpers.py

Repository: royerlab/napari-mcp

Length of output: 1672


🏁 Script executed:

#!/bin/bash
# Verify the test content at lines 449-460
sed -n '449,460p' tests/test_helpers.py

Repository: royerlab/napari-mcp

Length of output: 440


Type hint for line_limit should include None.

The test at line 457 passes line_limit=None, which the function handles gracefully via a try/except block that catches TypeError and defaults to 30. However, the type hint at line 332 of _helpers.py specifies line_limit: int | str, omitting None. Update the type hint to line_limit: int | str | None to match the intentional behavior documented in the test.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/test_helpers.py` around lines 449 - 460, Update the type hint for the
line_limit parameter to include None so it matches the function's runtime
behavior and the test; locate the function signature (e.g.,
build_truncated_response or the helper in _helpers.py where line_limit is
declared as line_limit: int | str) and change the annotation to line_limit: int
| str | None (or Optional[int | str]) to reflect that None is accepted and will
fallback to 30.

Comment on lines +66 to +70
actual_line_count = (
len([line for line in result.split("\n") if line]) if result else 0
)

# Property: Zoom level should remain positive
assert mock_viewer.camera.zoom > 0
assert actual_line_count <= max(line_limit, 0)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check how truncate_output counts lines
ast-grep --pattern $'def truncate_output($_) {
  $$$
}'

# Also search for the implementation
rg -n "def truncate_output" --type py -A 20

Repository: royerlab/napari-mcp

Length of output: 1185


🏁 Script executed:

cat -n src/napari_mcp/output.py

Repository: royerlab/napari-mcp

Length of output: 1165


Line counting logic in the test does not match truncate_output implementation.

The function uses splitlines(keepends=True) and counts ALL lines (line 30-31 in src/napari_mcp/output.py), but the test counts only non-empty lines with len([line for line in result.split("\n") if line]). This means the test can pass even if truncate_output produces more lines than the limit allows, as long as some lines are empty.

Fix the test to count lines the same way the function does:

actual_line_count = len(result.splitlines(keepends=True))
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/test_property_based.py` around lines 66 - 70, The test's line-counting
logic for variable result doesn't match truncate_output's behavior; change the
computation of actual_line_count to mirror truncate_output by using
result.splitlines(keepends=True) (and handle result being falsy as before) so
actual_line_count = len(result.splitlines(keepends=True)) — update the assertion
accordingly; reference variables: actual_line_count, result and function
truncate_output to locate the code to change.

Comment thread tests/test_qt_helpers.py
Comment on lines +138 to +139
if mock_shutdown.called:
mock_shutdown.assert_called_once()
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Conditional assertion may silently pass without verification.

If the Qt destroyed signal doesn't fire (which is noted as fragile in the comment), the test passes without asserting anything. This could hide regressions.

Consider making the test more deterministic:

🔧 Suggested approach
-        # If the Qt signal fired, request_shutdown was called
-        if mock_shutdown.called:
-            mock_shutdown.assert_called_once()
+        # Note: Qt signal firing is fragile in tests.
+        # At minimum, verify the signal connection was established.
+        assert napari_mcp_server._state.window_close_connected is True

Or skip the test if reliable verification isn't possible, rather than having a test that may not test anything.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/test_qt_helpers.py` around lines 138 - 139, The test currently only
asserts mock_shutdown.assert_called_once() inside an if mock_shutdown.called
guard so it can silently pass if the Qt destroyed signal never fires; change the
test to be deterministic by explicitly emitting or invoking the cleanup path
(e.g., trigger the widget's destroyed signal or call the method that should run
shutdown) and then assert mock_shutdown.assert_called_once() unconditionally, or
if you cannot reliably trigger the signal make the test skip (pytest.skip)
instead of silently passing; locate the assertion around mock_shutdown and the
code that connects/emits the Qt destroyed signal to implement this change.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
.github/workflows/test.yml (1)

190-199: Consider improving the safety check invocation.

Two observations:

  1. Safety check without arguments: Running safety check without specifying a requirements file may not scan the project's actual dependencies. Consider generating requirements first or using uv's export:

    run: |
      uv export --no-hashes > requirements.txt
      safety check -r requirements.txt
  2. Version consistency: The workflow installs bandit via pip install bandit (gets latest), while pyproject.toml declares bandit>=1.8.6. This is minor since the job is advisory, but could cause inconsistent results between local and CI runs.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.github/workflows/test.yml around lines 190 - 199, Update the "Install
security tools" and "Safety check" steps so the safety scan runs against an
exported requirements file and pin bandit to the project-declared version:
generate a requirements.txt from the project environment (e.g., using uv export
--no-hashes) before the "Safety check" step and invoke safety with -r
requirements.txt, and change the pip install for bandit in the "Install security
tools" step to match the pyproject constraint (e.g., install "bandit>=1.8.6") to
keep CI tooling consistent with local/dev requirements.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In @.github/workflows/test.yml:
- Around line 43-47: Update the GitHub Actions usage of the astral-sh/setup-uv
action by changing the version tag from "astral-sh/setup-uv@v6" to
"astral-sh/setup-uv@v7" wherever it appears (e.g., the occurrences around the
name: Install uv step and the second occurrence near line 96); keep the existing
inputs (enable-cache, cache-suffix) unchanged.

---

Nitpick comments:
In @.github/workflows/test.yml:
- Around line 190-199: Update the "Install security tools" and "Safety check"
steps so the safety scan runs against an exported requirements file and pin
bandit to the project-declared version: generate a requirements.txt from the
project environment (e.g., using uv export --no-hashes) before the "Safety
check" step and invoke safety with -r requirements.txt, and change the pip
install for bandit in the "Install security tools" step to match the pyproject
constraint (e.g., install "bandit>=1.8.6") to keep CI tooling consistent with
local/dev requirements.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: d5efb371-c0ec-4ad0-94b7-a2a89163f4c3

📥 Commits

Reviewing files that changed from the base of the PR and between 4a9369f and 052b5f0.

📒 Files selected for processing (1)
  • .github/workflows/test.yml

Comment on lines 43 to 47
- name: Install uv
uses: astral-sh/setup-uv@v4
uses: astral-sh/setup-uv@v6
with:
enable-cache: true
cache-suffix: "smoke"
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check available tags for setup-uv action
gh api repos/astral-sh/setup-uv/tags --jq '.[].name' | head -20

Repository: royerlab/napari-mcp

Length of output: 223


🌐 Web query:

astral-sh/setup-uv action releases versions v6

💡 Result:

astral-sh/setup-uv does have a v6 major line (usable as uses: astral-sh/setup-uv@v6, which floats to the latest v6.x.y). The last v6 release is v6.8.0 (“Add **/*.py.lock to cache-dependency-glob”, dated Sep 30). [1]

As of Mar 16, 2026, the latest overall major is v7 (latest release shown: v7.6.0), and Astral’s docs now show uses: astral-sh/setup-uv@v7 in examples. [2], [3]

Sources: [1] [2] [3]


Upgrade setup-uv action from v6 to v7.

The workflow currently uses astral-sh/setup-uv@v6, but v7 is now the latest stable release and is recommended in Astral's official documentation. This appears in lines 44 and 96.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.github/workflows/test.yml around lines 43 - 47, Update the GitHub Actions
usage of the astral-sh/setup-uv action by changing the version tag from
"astral-sh/setup-uv@v6" to "astral-sh/setup-uv@v7" wherever it appears (e.g.,
the occurrences around the name: Install uv step and the second occurrence near
line 96); keep the existing inputs (enable-cache, cache-suffix) unchanged.

@github-actions
Copy link
Copy Markdown
Contributor

✅ Documentation builds successfully! The site will be updated when this PR is merged.

@ilan-theodoro ilan-theodoro merged commit 5f13d34 into main Mar 17, 2026
16 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants