From 3dc9e69cf63409abd2566965a9d3df2abb86eb26 Mon Sep 17 00:00:00 2001 From: Neelay Shah Date: Fri, 30 Jan 2026 09:57:18 +0100 Subject: [PATCH] task(BE-5766): Introduce PAPI-HETA tools --- README.md | 19 +- pyproject.toml | 2 +- src/aignostics/cli.py | 94 +- src/aignostics/mcp/CLAUDE.md | 500 +++++++ src/aignostics/mcp/__init__.py | 9 + src/aignostics/mcp/_chart_template.html | 60 + src/aignostics/mcp/_charts.py | 378 ++++++ src/aignostics/mcp/_constants.py | 9 + src/aignostics/mcp/_server.py | 967 ++++++++++++++ .../skills/summarize-cell-readouts/SKILL.md | 217 ++++ .../mcp/skills/visualize-readouts/SKILL.md | 282 ++++ src/aignostics/utils/__init__.py | 11 +- src/aignostics/utils/_mcp.py | 20 +- tests/aignostics/cli_test.py | 2 +- tests/aignostics/mcp/server_test.py | 1151 +++++++++++++++++ tests/aignostics/utils/mcp_test.py | 8 +- uv.lock | 198 +-- 17 files changed, 3707 insertions(+), 220 deletions(-) create mode 100644 src/aignostics/mcp/CLAUDE.md create mode 100644 src/aignostics/mcp/__init__.py create mode 100644 src/aignostics/mcp/_chart_template.html create mode 100644 src/aignostics/mcp/_charts.py create mode 100644 src/aignostics/mcp/_constants.py create mode 100644 src/aignostics/mcp/_server.py create mode 100644 src/aignostics/mcp/skills/summarize-cell-readouts/SKILL.md create mode 100644 src/aignostics/mcp/skills/visualize-readouts/SKILL.md create mode 100644 tests/aignostics/mcp/server_test.py diff --git a/README.md b/README.md index 849f8cdd..9f09c49e 100644 --- a/README.md +++ b/README.md @@ -621,7 +621,17 @@ for the Google Storage Bucket** The Python SDK includes an MCP (Model Context Protocol) server that exposes SDK functionality to AI agents like Claude. This enables AI assistants to help you interact with the Aignostics Platform through natural conversation. -### Quick Start with Claude Desktop +### Quick Start with Claude Desktop (macOS) + +Run the following command to automatically configure Claude Desktop: + +```bash +uvx aignostics mcp install +``` + +This command adds the Aignostics MCP server to your Claude Desktop configuration file at `~/Library/Application Support/Claude/claude_desktop_config.json`. Restart Claude Desktop after running this command. + +**Manual configuration (Windows or custom setup):** Add the following to your Claude Desktop configuration file: @@ -644,8 +654,13 @@ Restart Claude Desktop after adding this configuration. ### CLI Commands ```bash -# Using uvx (no installation required) +# Configure Claude Desktop (macOS) +uvx aignostics mcp install + +# Run the MCP server manually uvx aignostics mcp run + +# List available tools uvx aignostics mcp list-tools ``` diff --git a/pyproject.toml b/pyproject.toml index 9ef19ad3..d93204fe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -138,7 +138,7 @@ dependencies = [ "lxml>=6.0.2", # For python 3.14 pre-built wheels "filelock>=3.20.1", # CVE-2025-68146 "marshmallow>=3.26.2", # CVE-2025-68480 - "fastmcp>=2.0.0,<3", # MCP server - Major version 3 is in beta as of 26/01/2026 and has not been released on PyPI. Upgrade once a stable release is out. + "fastmcp @ git+https://github.com/jlowin/fastmcp.git@c8e2c621ef9e568f698e99085ed5d9e5d96678c6", # MCP server - Pinned to commit with meta field preservation fix ] [project.optional-dependencies] diff --git a/src/aignostics/cli.py b/src/aignostics/cli.py index c5203d26..dc23d4e1 100644 --- a/src/aignostics/cli.py +++ b/src/aignostics/cli.py @@ -3,6 +3,7 @@ import sys from importlib.util import find_spec from pathlib import Path +from typing import Annotated import typer from loguru import logger @@ -31,8 +32,6 @@ def launchpad() -> None: if find_spec("marimo"): - from typing import Annotated - from aignostics.utils import create_marimo_app @cli.command() @@ -68,20 +67,99 @@ def notebook( mcp_cli = typer.Typer(name="mcp", help="MCP (Model Context Protocol) server for AI agent integration.") +@mcp_cli.command("install") +def mcp_install() -> None: + """Configure Claude Desktop to use the Aignostics MCP server. + + This command automatically adds the Aignostics MCP server configuration + to your Claude Desktop config file on macOS. After running this command, + restart Claude Desktop to load the MCP server. + + The configuration uses uvx, so no local installation is required. + + Examples: + aignostics mcp install + + Raises: + Exit: If not on macOS, if uvx is not found, or if user cancels. + """ + import json # noqa: PLC0415 + import platform # noqa: PLC0415 + import shutil # noqa: PLC0415 + + if platform.system() != "Darwin": + console.print("[red]Error:[/red] This command is only supported on macOS.", style="error") + raise typer.Exit(1) + + # Claude Desktop config path on macOS + config_path = Path.home() / "Library" / "Application Support" / "Claude" / "claude_desktop_config.json" + + # Find uvx binary + uvx_path = shutil.which("uvx") + if not uvx_path: + console.print("[red]Error:[/red] Could not find 'uvx' binary. Please install uv first.", style="error") + raise typer.Exit(1) + + # Build the server configuration using uvx + server_config = { + "command": uvx_path, + "args": ["aignostics", "mcp", "run"], + } + + # Load existing config or create new one + if config_path.exists(): + with config_path.open() as f: + config = json.load(f) + console.print(f"[dim]Found existing config at {config_path}[/dim]") + else: + config = {} + # Ensure parent directory exists + config_path.parent.mkdir(parents=True, exist_ok=True) + console.print(f"[dim]Creating new config at {config_path}[/dim]") + + # Initialize mcpServers if not present + if "mcpServers" not in config: + config["mcpServers"] = {} + + # Check if aignostics is already configured + if "aignostics" in config["mcpServers"]: + existing = config["mcpServers"]["aignostics"] + console.print("[yellow]Warning:[/yellow] Aignostics MCP server is already configured:") + console.print(f" command: {existing.get('command')}") + console.print(f" args: {existing.get('args')}") + if not typer.confirm("Do you want to overwrite the existing configuration?"): + console.print("[dim]Configuration unchanged.[/dim]") + raise typer.Exit(0) + + # Add or update the aignostics server config + config["mcpServers"]["aignostics"] = server_config + + # Write the config back + with config_path.open("w") as f: + json.dump(config, f, indent=2) + + console.print("\n[green]✓[/green] Claude Desktop configured successfully!") + console.print(f"\n[bold]Configuration written to:[/bold] {config_path}") + console.print("\n[bold]Server configuration:[/bold]") + console.print(f" command: {server_config['command']}") + console.print(f" args: {server_config['args']}") + console.print("\n[yellow]→[/yellow] Please restart Claude Desktop to load the MCP server.") + + @mcp_cli.command("run") def mcp_run() -> None: - """Run the MCP server. + """Run the MCP server with all tools including MCP Apps. - Starts an MCP server using `stdio` transport that exposes SDK functionality - to AI agents. The server automatically discovers and mounts tools from - the SDK and any installed plugins. + Starts an MCP server using stdio transport that exposes SDK functionality + to AI agents. The server discovers and mounts all available MCP servers, + including those with MCP Apps for interactive visualizations. Examples: uv run aignostics mcp run """ - from aignostics.utils import mcp_run # noqa: PLC0415 + from aignostics.utils import mcp_run_server # noqa: PLC0415 - mcp_run() + mcp_run_server() @mcp_cli.command("list-tools") diff --git a/src/aignostics/mcp/CLAUDE.md b/src/aignostics/mcp/CLAUDE.md new file mode 100644 index 00000000..29f2c9ce --- /dev/null +++ b/src/aignostics/mcp/CLAUDE.md @@ -0,0 +1,500 @@ +# CLAUDE.md - MCP Server Module + +This file provides guidance to Claude Code when working with the `mcp` module in this repository. + +## Module Overview + +The MCP (Model Context Protocol) module provides an MCP server implementation for AI agents to interact with Aignostics Platform readout data. It enables natural language analysis of cell and slide-level data from application runs. + +## Key Components + +**Core Files:** + +- `_server.py` - MCP server implementation with tools and resources +- `_charts.py` - Chart.js configuration builders for MCP Apps +- `_chart_template.html` - HTML template for interactive chart rendering (loaded via `@lru_cache`) +- `_constants.py` - Shared constants (`SERVER_NAME`, `MAX_CHART_POINTS`) to avoid circular imports +- `__init__.py` - Module exports (exposes `mcp` instance for auto-discovery) + +**Skills:** + +- `skills/summarize-cell-readouts/SKILL.md` - Guided workflow for cell analysis +- `skills/visualize-readouts/SKILL.md` - Interactive chart visualization workflow + +## Architecture + +### Data Flow + +``` +Platform API → Download Readouts → CSV Files → DuckDB Views → SQL Queries → Results + ↓ + Per-slide files: + cell_readouts_.csv + slide_readouts_.csv +``` + +### DuckDB Integration + +Readouts are stored as CSV files and queried via DuckDB views: + +- **UNION ALL Pattern**: Multiple per-slide CSV files are combined using UNION ALL for optimal performance (glob patterns were found to hang on large datasets) +- **`external_id` Column**: Added automatically to enable per-slide filtering in queries +- **Connection Caching**: DuckDB connections are cached per run for performance +- **Schema Caching**: Column information is cached to speed up error messages and schema lookups + +## Tools + +| Tool | Purpose | +|------|---------| +| `list_runs` | List recent runs with status/item counts | +| `get_run_status` | Detailed run status with statistics | +| `get_run_items` | Items in a run with external IDs, states, errors | +| `download_readouts` | Download slide/cell CSV readouts (per-slide files) | +| `query_readouts_sql` | Execute SQL on `slides`/`cells` tables | +| `get_readout_schema` | Show column names and types | +| `get_current_user` | Show authenticated user/org | +| `visualize_readouts` | Generate interactive charts (bar, pie, histogram, scatter, line) | + +## Per-Slide Filtering + +Each readout file is saved with the slide's external_id in the filename: + +``` +cell_readouts_slide001.tiff.csv +cell_readouts_slide002.tiff.csv +``` + +When creating DuckDB views, an `external_id` column is added to enable filtering: + +```sql +-- Query all slides +SELECT CELL_CLASS, COUNT(*) FROM cells GROUP BY CELL_CLASS + +-- Query specific slide (exact match after path sanitization) +SELECT CELL_CLASS, COUNT(*) FROM cells +WHERE external_id = 'slide001.tiff' +GROUP BY CELL_CLASS + +-- Query with partial match (recommended for user queries) +SELECT CELL_CLASS, COUNT(*) FROM cells +WHERE external_id LIKE '%slide001%' +GROUP BY CELL_CLASS + +-- List all available slides +SELECT DISTINCT external_id FROM cells +``` + +**Path Sanitization**: Path separators (`/`, `\`) in external_id are converted to underscores for filesystem safety. So `a/b/c/slide.tiff` becomes `a_b_c_slide.tiff` in the filename and `external_id` column. + +## Interactive Visualization (MCP Apps) + +The MCP server supports interactive chart visualization using **MCP Apps** - the official standard for embedding interactive UIs in AI chat clients like Claude Desktop. + +### Architecture + +``` +SQL Query (DuckDB) → Chart.js Config JSON → Tool Result → MCP App UI → Chart.js Rendering +``` + +**How One Tool + One Resource Supports All Chart Types:** + +The design separates **data preparation** (Python) from **rendering** (JavaScript): + +| Component | Role | Location | +|-----------|------|----------| +| `visualize_readouts` tool | Runs SQL, builds Chart.js config JSON | Python (server) | +| `ui://aignostics-platform/chart` resource | Generic HTML that renders any Chart.js config | Browser (iframe) | +| Chart.js library | Interprets config, renders interactive Canvas | Browser (from CDN) | + +The Python tool returns different configs based on `chart_type`: +```python +# chart_type="bar" → {"type": "bar", "data": {...}} +# chart_type="pie" → {"type": "pie", "data": {...}} +# chart_type="scatter" → {"type": "scatter", "data": {...}} +``` + +The UI resource is **chart-type agnostic** - it just passes the config to Chart.js: +```javascript +new Chart(canvas, config); // Chart.js handles bar/pie/scatter/etc based on config.type +``` + +### Supported Chart Types + +| Chart Type | Use Case | SQL Pattern | +|------------|----------|-------------| +| `bar` | Category comparisons | `SELECT category, COUNT(*) FROM cells GROUP BY category` | +| `pie` | Proportional breakdown | Same as bar, rendered as pie | +| `histogram` | Numeric distributions | `SELECT numeric_column FROM cells` (auto-binned) | +| `scatter` | Spatial/correlations | `SELECT x, y, color_category FROM cells` | +| `line` | Ordered trends | `SELECT ordered_category, value FROM ...` | + +### Data Flow + +1. **User asks**: "Show me a bar chart of cell types" +2. **Claude calls**: `visualize_readouts(run_id, "bar", "SELECT CELL_CLASS, COUNT(*) FROM cells GROUP BY CELL_CLASS")` +3. **Tool executes**: SQL via DuckDB, returns Chart.js config JSON +4. **Claude Desktop**: Fetches `ui://aignostics-platform/chart` resource +5. **HTML renders**: In sandboxed iframe, receives tool result via MCP Apps protocol +6. **Chart.js renders**: Interactive chart with hover tooltips + +### Example Chart Configurations + +**Bar Chart (Cell Distribution):** +```json +{ + "type": "bar", + "data": { + "labels": ["Lymphocyte", "Carcinoma", "Fibroblast"], + "datasets": [{ + "data": [1000, 500, 300], + "backgroundColor": "rgba(54, 162, 235, 0.8)" + }] + }, + "_meta": { + "title": "Cell Distribution" + } +} +``` + +**Scatter Chart (Spatial Distribution):** +```json +{ + "type": "scatter", + "data": { + "datasets": [ + {"label": "Lymphocyte", "data": [{"x": 100, "y": 200}, ...], "backgroundColor": "blue"}, + {"label": "Carcinoma", "data": [{"x": 150, "y": 250}, ...], "backgroundColor": "red"} + ] + }, + "_meta": { + "title": "Cell Spatial Distribution", + "row_count": 5000, + "truncated": true, + "truncation_message": "Data limited to 5000 points for performance" + } +} +``` + +### Security Model + +- All UIs run in **sandboxed iframes** (MCP Apps standard) +- **CSP policy**: Only allows Chart.js from CDN (`cdn.jsdelivr.net`, `unpkg.com`) +- **User data stays local**: DuckDB queries local CSV files +- **Auditable communication**: All UI-to-host messages go through JSON-RPC + +## Resources + +MCP resources provide schema information and UI components: + +- `readouts://schema/cell` - Cell readout column schema (static, discovered from first run) +- `readouts://schema/slide` - Slide readout column schema (static, discovered from first run) +- `ui://aignostics-platform/chart` - Interactive chart viewer (MCP App) + +### Schema Resource Design + +**Why static URIs?** Schema resources use static URIs (no `run_id` in the path) because the schema is identical across all runs. The schema is discovered from the first run that downloads readouts and then cached globally. Claude can read `readouts://schema/cell` without needing to know a specific run_id. + +**Schema cache behavior:** +- Schema is cached globally by readout type (`cell` or `slide`), not per-run +- Once discovered from any run, the schema is available for all subsequent queries +- The schema cache is **NOT** cleared when DuckDB connections are cleared (e.g., when re-downloading readouts) +- This is intentional: schema is a property of the application output format, not individual runs +- If the application version changes and introduces new columns, restart the MCP server to pick up the new schema + +## Usage Patterns + +### Basic Analysis Workflow + +```python +# 1. List available runs +list_runs(limit=5) + +# 2. Download readouts for a run (also populates global schema cache) +download_readouts(run_id="abc-123") + +# 3. Check available columns (schema is now cached and available via static resource) +# Option A: Use the tool +get_readout_schema(run_id="abc-123", readout_type="cell") +# Option B: Read the static resource (no run_id needed after first download) +# readouts://schema/cell + +# 4. List available slides +query_readouts_sql(run_id="abc-123", sql="SELECT DISTINCT external_id FROM cells") + +# 5. Run analysis queries +query_readouts_sql( + run_id="abc-123", + sql=""" + SELECT CELL_CLASS, COUNT(*) as count + FROM cells + GROUP BY CELL_CLASS + ORDER BY count DESC + """ +) +``` + +### Per-Slide Analysis + +```python +# Filter to specific slide +query_readouts_sql( + run_id="abc-123", + sql=""" + SELECT CELL_CLASS, COUNT(*) as count + FROM cells + WHERE external_id LIKE '%my_slide.tiff' + GROUP BY CELL_CLASS + """ +) +``` + +## Configuration + +**Environment Variables:** + +- `AIGNOSTICS_MCP_READOUTS_DIR` - Custom directory for downloaded readouts (default: `~/aignostics_readouts`) + +**Cache Structure:** + +``` +~/aignostics_readouts/ +└── / + ├── cell_readouts_.csv + ├── cell_readouts_.csv + ├── slide_readouts_.csv + └── slide_readouts_.csv +``` + +## Authentication + +The MCP server uses the same authentication as the SDK: + +- Tokens cached in `~/.aignostics/token.json` +- Auto-retry on `UnauthorizedException` (clears token and retries once) +- Device flow authentication if no valid token exists + +## Claude Desktop Integration + +### Prerequisites + +**Install the SDK** (if not using uvx): +```bash +uv pip install aignostics +``` + +Note: Authentication is handled automatically when you first use the MCP tools. A browser window will open for device flow authentication, and the token is cached at `~/.aignostics/token.json`. + +### Setup + +1. **Locate your Claude Desktop config file**: + - macOS: `~/Library/Application Support/Claude/claude_desktop_config.json` + - Windows: `%APPDATA%\Claude\claude_desktop_config.json` + +2. **Add the Aignostics MCP server configuration**: + + **Option A - Using uvx (recommended, no installation needed):** + ```json + { + "mcpServers": { + "aignostics": { + "command": "uvx", + "args": ["--with", "aignostics", "aignostics", "mcp", "run"] + } + } + } + ``` + + **Option B - Using a local installation:** + ```json + { + "mcpServers": { + "aignostics": { + "command": "aignostics", + "args": ["mcp", "run"] + } + } + } + ``` + + **Option C - Using a local development version:** + ```json + { + "mcpServers": { + "aignostics": { + "command": "/path/to/.local/bin/uv", + "args": [ + "run", + "--directory", + "/path/to/python-sdk", + "aignostics", + "mcp", + "run" + ], + "env": { + "AIGNOSTICS_API_ROOT": "https://platform.aignostics.com" + } + } + } + } + ``` + + Replace `/path/to/.local/bin/uv` with the output of `which uv` and `/path/to/python-sdk` with your SDK directory. + +3. **Restart Claude Desktop** to load the new MCP server. + +### Available Tools + +Once configured, Claude Desktop will have access to these tools: + +| Tool | Description | +|------|-------------| +| `list_runs` | List your recent application runs | +| `get_run_status` | Get detailed status of a specific run | +| `get_run_items` | List items/slides in a run | +| `download_readouts` | Download readout data for analysis | +| `query_readouts_sql` | Run SQL queries on readout data | +| `get_readout_schema` | Inspect available columns | +| `get_current_user` | Verify authentication | +| `visualize_readouts` | Generate interactive charts (auto-limited to 5,000 points) | + +### Using Skills + +Skills are guided workflows that help Claude perform complex analysis tasks. They provide step-by-step instructions for common use cases. + +#### Available Skills + +| Skill | Location | Purpose | +|-------|----------|---------| +| `summarize-cell-readouts` | `skills/summarize-cell-readouts/SKILL.md` | Cell count analysis, distributions, tissue regions | +| `visualize-readouts` | `skills/visualize-readouts/SKILL.md` | Interactive chart visualization (bar, pie, scatter, etc.) | + +#### Skill Discovery + +Skills are discovered by Claude based on keyword matching in their YAML frontmatter `description` field. The descriptions include: +- Trigger phrases (e.g., "scatter plot", "cell positions", "visualize") +- Common user queries (e.g., "show me", "how many cells", "cell breakdown") +- **CRITICAL reminder**: Always check the schema first via resources (`readouts://schema/cell`, `readouts://schema/slide`) or `get_readout_schema()` tool + +#### How to Use Skills with Claude Desktop + +**Option 1: Reference the skill in your prompt (recommended)** + +Simply describe what you want, and Claude will use the appropriate tools: +``` +"Summarize the cells in my latest run" +"How many carcinoma cells are there in run abc-123?" +"Show me the cell distribution by tissue region" +``` + +**Option 2: Provide the skill as context** + +For more guided analysis, paste the skill content into your conversation: + +1. Open the skill file (e.g., `skills/summarize-cell-readouts/SKILL.md`) +2. Copy the content +3. Paste it into Claude Desktop with a message like: + ``` + Please follow this workflow to analyze my run abc-123: + + [paste skill content here] + ``` + +#### Skill: `summarize-cell-readouts` + +Analyzes cell-level readout data from application runs. Use it when you want to: +- Get cell counts and statistics +- Understand cell type distributions +- Analyze tissue region membership +- Compare cell populations + +The skill guides Claude through: +1. Downloading readouts +2. Checking the schema for available columns +3. Running SQL queries for cell distributions +4. Presenting results in a clear format + +### Example Conversations + +**Basic run exploration:** +``` +You: What runs do I have? +Claude: [calls list_runs] You have 3 recent runs... + +You: Tell me about run abc-123 +Claude: [calls get_run_status] This run processed 10 slides... +``` + +**Cell analysis:** +``` +You: Summarize the cells in run abc-123 +Claude: [follows summarize-cell-readouts skill] + [calls download_readouts, get_readout_schema, query_readouts_sql] + Here's the cell summary: + - Total cells: 4.5 million + - Carcinoma cells: 36% + - Lymphocytes: 17% + ... +``` + +**Custom SQL queries:** +``` +You: Show me the average nucleus area by cell type +Claude: [calls query_readouts_sql with custom SQL] + Here are the results... +``` + +**Interactive visualization:** +``` +You: Show me a bar chart of cell types +Claude: [calls visualize_readouts with chart_type="bar"] + [Interactive chart appears in conversation] + +You: Plot the spatial distribution of cells colored by type +Claude: [calls visualize_readouts with chart_type="scatter", color_column="CELL_CLASS"] + [Scatter plot with colored points appears] +``` + +### Troubleshooting + +**"Not authenticated" errors:** +- Run `aignostics user login` (or `uvx aignostics user login`) to authenticate +- Check that `~/.aignostics/token.json` exists and is not expired + +**Server not appearing in Claude Desktop:** +- Verify the config file path is correct +- Check JSON syntax in the config file +- Restart Claude Desktop completely (quit and reopen) +- Check Claude Desktop logs for errors + +**Slow queries:** +- First query downloads readouts (can take time for large runs) +- Subsequent queries use cached data and are fast + +## Development Notes + +### Adding New Tools + +1. Add function with `@mcp.tool()` decorator +2. Add `@_retry_on_auth_failure` if it calls the Platform API +3. Use `Client()` directly for authenticated API access +4. Use `_resolve_run_id()` to accept both run_id and external_id +5. Return markdown-formatted strings for best display + +### Performance Considerations + +- **UNION ALL vs Glob**: Use UNION ALL to combine CSV files (glob patterns hang on large datasets) +- **Connection Caching**: DuckDB connections are reused per run +- **Schema Caching**: Avoid repeated DESCRIBE queries +- **Lazy Download**: Readouts are only downloaded when first queried (via `_ensure_readouts_exist()`) +- **HTML Template Caching**: Chart HTML template loaded once via `@lru_cache` +- **Chart Point Limiting**: `visualize_readouts` automatically limits data to `MAX_CHART_POINTS` (5,000) for browser performance and MCP client tool result size limits; truncation is indicated in `_meta` +- **SQL Result Limiting**: `query_readouts_sql` limits results to `MAX_SQL_RESULT_ROWS` (100) for readable text output + +### Testing + +Unit tests are in `tests/aignostics/mcp/server_test.py`: + +- Test tools by calling the decorated functions directly +- Mock `Client` class for API calls: `patch("aignostics.mcp._server.Client")` +- Use temporary directories for readout files +- Clear caches between tests with `clean_caches` fixture diff --git a/src/aignostics/mcp/__init__.py b/src/aignostics/mcp/__init__.py new file mode 100644 index 00000000..065a798e --- /dev/null +++ b/src/aignostics/mcp/__init__.py @@ -0,0 +1,9 @@ +"""MCP server module for exposing readout tools to AI agents. + +This module provides an MCP server that exposes tools for querying +and analyzing application run readouts from the Aignostics Platform. +""" + +from ._server import mcp + +__all__ = ["mcp"] diff --git a/src/aignostics/mcp/_chart_template.html b/src/aignostics/mcp/_chart_template.html new file mode 100644 index 00000000..58b6f520 --- /dev/null +++ b/src/aignostics/mcp/_chart_template.html @@ -0,0 +1,60 @@ + + + + + + + + + +
+
+
+ +
+ + + + diff --git a/src/aignostics/mcp/_charts.py b/src/aignostics/mcp/_charts.py new file mode 100644 index 00000000..e88ddecb --- /dev/null +++ b/src/aignostics/mcp/_charts.py @@ -0,0 +1,378 @@ +"""Chart configuration and HTML generation for MCP Apps visualization. + +This module provides Chart.js configuration builders and HTML templates for +interactive chart visualization in MCP Apps-compatible clients like Claude Desktop. + +Architecture: + SQL Query (DuckDB) -> Chart.js Config JSON -> Tool Result -> MCP App UI -> Chart.js Rendering + +The design separates data preparation (Python) from rendering (JavaScript): +- visualize_readouts tool: Runs SQL, builds Chart.js config JSON +- ui://aignostics-platform/chart resource: Generic HTML that renders any Chart.js config +- Chart.js library: Interprets config, renders interactive SVG/Canvas +""" + +# ruff: noqa: PLR0913, PLR0917, C901, DOC201 + +from __future__ import annotations + +import contextlib +from functools import lru_cache +from importlib import resources +from typing import TYPE_CHECKING, Any, Literal + +if TYPE_CHECKING: + import duckdb + +from itertools import starmap + +from aignostics.mcp._constants import MAX_CHART_POINTS, SERVER_NAME + +# Chart types supported by this module +ChartType = Literal["bar", "pie", "histogram", "scatter", "line"] + +# View URI for the chart MCP App resource +# When the server is mounted with prefix=SERVER_NAME, resources are prefixed too. +# The tool metadata references this full URI so clients can find the resource. +VIEW_URI = f"ui://{SERVER_NAME}/chart" + +# Local resource path (without prefix) for registering on the server +# FastMCP will prefix this with SERVER_NAME when mounted +RESOURCE_PATH = "ui://chart" + + +@lru_cache(maxsize=1) +def get_chart_html() -> str: + """Get the chart HTML template, loading from file on first access.""" + return resources.files("aignostics.mcp").joinpath("_chart_template.html").read_text() + + +def build_chart_config( + chart_type: ChartType, + labels: list[str] | None = None, + values: list[float | int] | None = None, + x_values: list[float | int] | None = None, + y_values: list[float | int] | None = None, + color_values: list[str] | None = None, + title: str | None = None, + x_label: str | None = None, + y_label: str | None = None, + bins: int = 20, +) -> dict[str, Any]: + """Build Chart.js configuration for any supported chart type. + + Args: + chart_type: Type of chart ('bar', 'pie', 'histogram', 'scatter', 'line'). + labels: Category labels for bar/pie/line charts. + values: Numeric values for bar/pie/line/histogram charts. + x_values: X-axis values for scatter charts. + y_values: Y-axis values for scatter charts. + color_values: Color grouping labels for scatter charts. + title: Optional chart title. + x_label: Optional x-axis label. + y_label: Optional y-axis label. + bins: Number of bins for histogram (default 20). + + Returns: + Chart.js configuration dictionary. + """ + match chart_type: + case "bar": + return _bar_config(labels or [], values or [], title, x_label, y_label) + case "pie": + return _pie_config(labels or [], values or [], title) + case "histogram": + return _histogram_config(values or [], bins, title, x_label) + case "scatter": + return _scatter_config(x_values or [], y_values or [], color_values, title, x_label, y_label) + case "line": + return _line_config(labels or [], values or [], title, x_label, y_label) + + +def _bar_config( + labels: list[str], + values: list[float | int], + title: str | None, + x_label: str | None, + y_label: str | None, +) -> dict[str, Any]: + """Build bar chart configuration.""" + config: dict[str, Any] = { + "type": "bar", + "data": { + "labels": labels, + "datasets": [{"data": values, "backgroundColor": "rgba(54, 162, 235, 0.8)", "borderWidth": 1}], + }, + "options": { + "responsive": True, + "plugins": {"legend": {"display": False}}, + "scales": {"y": {"beginAtZero": True}}, + }, + "_meta": {"title": title}, + } + if x_label: + config["options"]["scales"]["x"] = {"title": {"display": True, "text": x_label}} + if y_label: + config["options"]["scales"]["y"]["title"] = {"display": True, "text": y_label} + return config + + +def _pie_config(labels: list[str], values: list[float | int], title: str | None) -> dict[str, Any]: + """Build pie chart configuration.""" + return { + "type": "pie", + "data": { + "labels": labels, + "datasets": [{"data": values, "backgroundColor": _generate_colors(len(labels)), "borderWidth": 2}], + }, + "options": {"responsive": True, "plugins": {"legend": {"position": "right"}}}, + "_meta": {"title": title}, + } + + +def _histogram_config( + values: list[float | int], + bins: int, + title: str | None, + x_label: str | None, +) -> dict[str, Any]: + """Build histogram configuration (rendered as bar chart).""" + if not values: + return _empty_chart_config("No data available for histogram") + + min_val, max_val = min(values), max(values) + + if min_val == max_val: + bin_labels, counts = [f"{min_val:.2f}"], [len(values)] + else: + bin_width = (max_val - min_val) / bins + bin_edges = [min_val + i * bin_width for i in range(bins + 1)] + counts = [0] * bins + for v in values: + bin_idx = min(int((v - min_val) / bin_width), bins - 1) + counts[bin_idx] += 1 + bin_labels = [f"{bin_edges[i]:.1f}-{bin_edges[i + 1]:.1f}" for i in range(bins)] + + return { + "type": "bar", + "data": { + "labels": bin_labels, + "datasets": [ + { + "data": counts, + "backgroundColor": "rgba(75, 192, 192, 0.8)", + "borderWidth": 1, + "barPercentage": 1.0, + "categoryPercentage": 1.0, + } + ], + }, + "options": { + "responsive": True, + "plugins": {"legend": {"display": False}}, + "scales": { + "x": {"title": {"display": bool(x_label), "text": x_label or ""}}, + "y": {"beginAtZero": True, "title": {"display": True, "text": "Count"}}, + }, + }, + "_meta": {"title": title, "subtitle": f"{len(values):,} values in {bins} bins"}, + } + + +def _scatter_config( + x_values: list[float | int], + y_values: list[float | int], + color_values: list[str] | None, + title: str | None, + x_label: str | None, + y_label: str | None, +) -> dict[str, Any]: + """Build scatter chart configuration.""" + if not x_values or not y_values: + return _empty_chart_config("No data available for scatter plot") + + # Round coordinates to 2 decimal places to reduce JSON size + def pt(x: float, y: float) -> dict[str, float]: + return {"x": round(float(x), 2), "y": round(float(y), 2)} + + if color_values: + unique_labels = list(set(color_values)) + colors = _generate_colors(len(unique_labels)) + datasets = [] + for i, label in enumerate(unique_labels): + points = [pt(x, y) for x, y, c in zip(x_values, y_values, color_values, strict=True) if c == label] + datasets.append({"label": str(label), "data": points, "backgroundColor": colors[i], "pointRadius": 4}) + else: + points = list(starmap(pt, zip(x_values, y_values, strict=True))) + datasets = [{"data": points, "backgroundColor": "rgba(54, 162, 235, 0.6)", "pointRadius": 4}] + + return { + "type": "scatter", + "data": {"datasets": datasets}, + "options": { + "responsive": True, + "plugins": {"legend": {"display": bool(color_values)}}, + "scales": { + "x": {"title": {"display": bool(x_label), "text": x_label or ""}}, + "y": {"title": {"display": bool(y_label), "text": y_label or ""}}, + }, + }, + "_meta": {"title": title, "subtitle": f"{len(x_values):,} points"}, + } + + +def _line_config( + labels: list[str], + values: list[float | int], + title: str | None, + x_label: str | None, + y_label: str | None, +) -> dict[str, Any]: + """Build line chart configuration.""" + if not labels or not values: + return _empty_chart_config("No data available for line chart") + + return { + "type": "line", + "data": { + "labels": labels, + "datasets": [{"data": values, "borderColor": "rgba(255, 99, 132, 1)", "fill": False, "tension": 0.1}], + }, + "options": { + "responsive": True, + "plugins": {"legend": {"display": False}}, + "scales": { + "x": {"title": {"display": bool(x_label), "text": x_label or ""}}, + "y": {"title": {"display": bool(y_label), "text": y_label or ""}}, + }, + }, + "_meta": {"title": title}, + } + + +def build_chart_from_sql_result( + result: duckdb.DuckDBPyConnection, + chart_type: ChartType, + title: str | None = None, + x_column: str | None = None, + y_column: str | None = None, + color_column: str | None = None, +) -> dict[str, Any]: + """Build a chart configuration from a DuckDB query result. + + This function transforms SQL query results into Chart.js configurations. + It auto-detects columns if not specified. Results are automatically limited + to MAX_CHART_POINTS for performance. + + Args: + result: DuckDB query result. + chart_type: Type of chart to generate. + title: Optional chart title. + x_column: Column for x-axis/labels (auto-detected if None). + y_column: Column for y-axis/values (auto-detected if None). + color_column: Column for color grouping (scatter only). + + Returns: + Chart.js configuration dictionary. Includes truncation info in '_meta' if data was limited. + """ + rows = result.fetchmany(MAX_CHART_POINTS) + truncated = result.fetchone() is not None + columns = [col[0] for col in result.description] if result.description else [] + + if not rows or not columns: + return _empty_chart_config("Query returned no results") + + # Auto-detect columns + if not x_column: + x_column = columns[0] + if not y_column: + y_column = columns[1] if len(columns) > 1 else columns[0] + + # Get column indices + try: + x_idx = columns.index(x_column) + except ValueError: + return _empty_chart_config(f"Column '{x_column}' not found in result") + + try: + y_idx = columns.index(y_column) + except ValueError: + return _empty_chart_config(f"Column '{y_column}' not found in result") + + color_idx = None + if color_column: + with contextlib.suppress(ValueError): + color_idx = columns.index(color_column) + + # Extract data + x_values = [row[x_idx] for row in rows] + y_values = [row[y_idx] for row in rows] + color_values = [str(row[color_idx]) for row in rows] if color_idx is not None else None + + # Build chart using unified function + if chart_type == "scatter": + config = build_chart_config( + chart_type="scatter", + x_values=[float(x) if x is not None else 0 for x in x_values], + y_values=[float(y) if y is not None else 0 for y in y_values], + color_values=color_values, + title=title, + x_label=x_column, + y_label=y_column, + ) + elif chart_type == "histogram": + config = build_chart_config( + chart_type="histogram", + values=[float(y) if y is not None else 0 for y in y_values], + title=title, + x_label=y_column, + ) + else: + # bar, pie, line all use labels + values + config = build_chart_config( + chart_type=chart_type, + labels=[str(x) for x in x_values], + values=[float(y) if y is not None else 0 for y in y_values], + title=title, + x_label=x_column, + y_label=y_column, + ) + + # Add row count and truncation info to metadata + if "_meta" not in config: + config["_meta"] = {} + config["_meta"]["row_count"] = len(rows) + if truncated: + config["_meta"]["truncated"] = True + config["_meta"]["truncation_message"] = f"Data limited to {len(rows)} points for performance" + + return config + + +def _generate_colors(n: int) -> list[str]: + """Generate a list of distinct colors for chart elements.""" + palette = [ + "rgba(54, 162, 235, 0.8)", + "rgba(255, 99, 132, 0.8)", + "rgba(75, 192, 192, 0.8)", + "rgba(255, 206, 86, 0.8)", + "rgba(153, 102, 255, 0.8)", + "rgba(255, 159, 64, 0.8)", + "rgba(199, 199, 199, 0.8)", + "rgba(83, 102, 255, 0.8)", + "rgba(255, 99, 255, 0.8)", + "rgba(99, 255, 132, 0.8)", + ] + if n <= len(palette): + return palette[:n] + + colors = list(palette) + for i in range(len(palette), n): + hue = (i * 137.5) % 360 + colors.append(f"hsla({hue}, 70%, 60%, 0.8)") + return colors + + +def _empty_chart_config(message: str) -> dict[str, Any]: + """Create an error configuration when no data is available.""" + return {"error": message, "_meta": {"title": "No Data"}} diff --git a/src/aignostics/mcp/_constants.py b/src/aignostics/mcp/_constants.py new file mode 100644 index 00000000..b64f608e --- /dev/null +++ b/src/aignostics/mcp/_constants.py @@ -0,0 +1,9 @@ +"""Shared constants for the MCP module.""" + +# Server name used for mounting and tool name prefixes +# Must be valid for tool name prefixes (no spaces, only [a-zA-Z0-9_-]) +SERVER_NAME = "aignostics-platform" + +# Maximum data points for chart visualizations (for performance and tool result size limits) +# 5000 points keeps scatter chart JSON under ~200KB to avoid MCP client size limits +MAX_CHART_POINTS = 5000 diff --git a/src/aignostics/mcp/_server.py b/src/aignostics/mcp/_server.py new file mode 100644 index 00000000..f66ecb4e --- /dev/null +++ b/src/aignostics/mcp/_server.py @@ -0,0 +1,967 @@ +"""MCP Server implementation for Aignostics Platform readouts. + +Uses DuckDB for high-performance SQL querying of readout data. + +Note: SQL injection warnings (S608) are intentionally suppressed - this MCP tool +is designed to allow LLMs/users to run arbitrary SQL queries on local CSV data. + +The SLF001 warnings for accessing Client._api_client_cached and _api_client_uncached +are suppressed as this is necessary to clear the cached client on auth failures. +""" + +# ruff: noqa: S608, SLF001, C901, PLR0912, PLR0913, PLR0917 + +from __future__ import annotations + +import json +import os +from functools import wraps +from itertools import islice +from pathlib import Path +from typing import TYPE_CHECKING, Any, Literal, ParamSpec, TypeVar + +import duckdb +import requests +from aignx.codegen.exceptions import NotFoundException, UnauthorizedException +from fastmcp import FastMCP +from fastmcp.server.apps import ResourceCSP, ResourceUI, ToolUI +from fastmcp.tools.tool import ToolResult +from loguru import logger +from mcp import types + +from aignostics.mcp._charts import RESOURCE_PATH, VIEW_URI, build_chart_from_sql_result, get_chart_html +from aignostics.mcp._constants import SERVER_NAME +from aignostics.platform import Client, ItemOutput +from aignostics.platform._authentication import remove_cached_token + +if TYPE_CHECKING: + from collections.abc import Callable, Sequence + +# Type variables for the retry decorator +P = ParamSpec("P") +R = TypeVar("R") + +# Constants +EXTERNAL_ID_MAX_LEN = 30 +MAX_SQL_RESULT_ROWS = 100 + +# CSP metadata for the chart viewer - allows loading Chart.js and MCP Apps SDK from CDN +# - unpkg.com: MCP Apps SDK (@modelcontextprotocol/ext-apps) +# - esm.sh: Chart.js (properly bundled ES module with all dependencies) +CHART_RESOURCE_UI = ResourceUI(csp=ResourceCSP(resource_domains=["https://unpkg.com", "https://esm.sh"])) # pyright: ignore[reportCallIssue] + +# Initialize MCP server +mcp = FastMCP(SERVER_NAME) + +# Default cache directory for downloaded readouts +DEFAULT_CACHE_DIR = Path.home() / "aignostics_readouts" + +# Cache for DuckDB connections per run_id +_duckdb_connections: dict[str, duckdb.DuckDBPyConnection] = {} + +# Cache for schema data by readout_type (schema is identical across all runs) +_schema_cache: dict[str, list[tuple[str, str]]] = {} + + +# ============================================================================= +# Settings and Configuration +# ============================================================================= + + +def _get_readouts_dir() -> Path: + """Get the readouts directory from environment or use default. + + The directory can be configured via AIGNOSTICS_MCP_READOUTS_DIR. + Default is ~/aignostics_readouts. + + Returns: + Path to the readouts directory. + """ + env_dir = os.environ.get("AIGNOSTICS_MCP_READOUTS_DIR") + if env_dir: + return Path(env_dir) + return DEFAULT_CACHE_DIR + + +def _get_cache_dir() -> Path: + """Get the cache directory, creating it if needed. + + Returns: + Path to the MCP cache directory. + """ + cache_dir = _get_readouts_dir() + cache_dir.mkdir(parents=True, exist_ok=True) + return cache_dir + + +def _get_readout_cache_dir(run_id: str) -> Path: + """Get the cache directory for a run's readouts. + + Args: + run_id: The run ID. + + Returns: + Path to the run's readout cache directory. + """ + cache_dir = _get_cache_dir() / run_id + cache_dir.mkdir(parents=True, exist_ok=True) + return cache_dir + + +def _get_readout_cache_path(run_id: str, readout_type: str, external_id: str) -> Path: + """Get the cache path for a specific readout file. + + Args: + run_id: The run ID. + readout_type: Type of readout ('slide' or 'cell'). + external_id: External ID to include in filename for per-slide filtering. + + Returns: + Path to the cached readout file. + """ + cache_dir = _get_readout_cache_dir(run_id) + # Sanitize external_id for use in filename (replace path separators) + safe_id = external_id.replace("/", "_").replace("\\", "_") + return cache_dir / f"{readout_type}_readouts_{safe_id}.csv" + + +def _has_readout_files(run_id: str, readout_type: str) -> bool: + """Check if any readout files exist for a run. + + Args: + run_id: The run ID. + readout_type: Type of readout ('slide' or 'cell'). + + Returns: + True if at least one readout file exists. + """ + cache_dir = _get_readout_cache_dir(run_id) + return bool(list(cache_dir.glob(f"{readout_type}_readouts_*.csv"))) + + +# ============================================================================= +# DuckDB Connection Caching +# ============================================================================= + + +def _extract_external_id_from_filename(filename: str, readout_type: str) -> str: + """Extract the external_id from a readout filename. + + Args: + filename: The filename (e.g., 'cell_readouts_slide_xyz.tiff.csv'). + readout_type: Type of readout ('slide' or 'cell'). + + Returns: + The extracted external_id (e.g., 'slide_xyz.tiff'). + """ + prefix = f"{readout_type}_readouts_" + suffix = ".csv" + return filename[len(prefix) : -len(suffix)] + + +def _build_union_all_query(run_id: str, readout_type: str) -> str | None: + """Build a UNION ALL query for all readout files of a given type. + + Uses UNION ALL instead of glob patterns for better DuckDB performance + with multiple CSV files. Adds an 'external_id' column to enable easy + per-slide filtering in queries. + + Args: + run_id: The run ID. + readout_type: Type of readout ('slide' or 'cell'). + + Returns: + SQL query string or None if no files found. + """ + cache_dir = _get_readout_cache_dir(run_id) + files = sorted(cache_dir.glob(f"{readout_type}_readouts_*.csv")) + + if not files: + return None + + # Add external_id column to enable per-slide filtering + # Note: path separators in external_id are sanitized to underscores in filenames + union_parts = [] + for f in files: + external_id = _extract_external_id_from_filename(f.name, readout_type) + # Escape single quotes in SQL strings ('' is the SQL escape for ') + safe_id = external_id.replace("'", "''") + safe_path = str(f).replace("'", "''") + union_parts.append( + f"SELECT *, '{safe_id}' as external_id " + f"FROM read_csv_auto('{safe_path}', header=true, skip=1)" + ) + + return " UNION ALL ".join(union_parts) + + +def _get_duckdb_connection(run_id: str) -> duckdb.DuckDBPyConnection: + """Get or create a cached DuckDB connection with views for a run. + + Creates the connection and views once per run, reusing for subsequent queries. + Uses UNION ALL to combine all per-slide readout files, with an 'external_id' column + added to enable per-slide filtering (e.g., WHERE external_id LIKE '%slide.tiff'). + + Args: + run_id: The run ID. + + Returns: + DuckDB connection with slides and cells views configured. + + Raises: + FileNotFoundError: If no readout files exist for this run. + """ + if run_id in _duckdb_connections: + return _duckdb_connections[run_id] + + has_slides = _has_readout_files(run_id, "slide") + has_cells = _has_readout_files(run_id, "cell") + + if not has_slides and not has_cells: + msg = f"No readout files found for run {run_id}" + raise FileNotFoundError(msg) + + con = duckdb.connect() + + if has_slides: + slide_query = _build_union_all_query(run_id, "slide") + if slide_query: + con.execute(f"CREATE VIEW slides AS {slide_query}") + logger.debug(f"Created slides view for run {run_id} using UNION ALL") + if has_cells: + cell_query = _build_union_all_query(run_id, "cell") + if cell_query: + con.execute(f"CREATE VIEW cells AS {cell_query}") + logger.debug(f"Created cells view for run {run_id} using UNION ALL") + + _duckdb_connections[run_id] = con + return con + + +def _clear_duckdb_connection(run_id: str) -> None: + """Clear a cached DuckDB connection for a run. + + Called after re-downloading readouts to ensure fresh data is used. + Note: Schema cache is NOT cleared because schema is identical across all runs. + + Args: + run_id: The run ID. + """ + if run_id in _duckdb_connections: + try: + _duckdb_connections[run_id].close() + except Exception: + logger.debug(f"Failed to close DuckDB connection for run {run_id}") + del _duckdb_connections[run_id] + logger.debug(f"Cleared DuckDB connection for run {run_id}") + + +def _get_schema(run_id: str, readout_type: str) -> list[tuple[str, str]]: + """Get cached schema for a readout type. + + Returns schema from first matching file plus the 'external_id' column + that is added when combining files via UNION ALL to enable per-slide filtering. + + Schema is cached globally by readout_type (not per-run) because the schema + is identical across all runs. The run_id is only needed to find files if + the schema hasn't been cached yet. + + Args: + run_id: The run ID (used to find files if schema not yet cached). + readout_type: Type of readout ('slide' or 'cell'). + + Returns: + List of (column_name, column_type) tuples. + """ + # Schema is cached globally by type since it's identical across all runs + if readout_type in _schema_cache: + return _schema_cache[readout_type] + + if not _has_readout_files(run_id, readout_type): + return [] + + # Get schema from first file + cache_dir = _get_readout_cache_dir(run_id) + files = sorted(cache_dir.glob(f"{readout_type}_readouts_*.csv")) + if not files: + return [] + + first_file = files[0] + con = duckdb.connect() + result = con.execute(f"DESCRIBE SELECT * FROM read_csv_auto('{first_file}', header=true, skip=1)") + schema = [(row[0], row[1]) for row in result.fetchall()] + # Add external_id column that UNION ALL adds for per-slide filtering + schema.append(("external_id", "VARCHAR")) + _schema_cache[readout_type] = schema + return schema + + +# ============================================================================= +# Helper Functions +# ============================================================================= + + +def _clear_client_cache() -> None: + """Clear the cached API client instances. + + This forces re-authentication on the next API call. + """ + Client._api_client_cached = None + Client._api_client_uncached = None + + +def _markdown_table(headers: Sequence[str], rows: Sequence[Sequence[Any]]) -> str: + """Format data as a markdown table. + + Args: + headers: Column headers. + rows: List of rows, each row is a sequence of cell values. + + Returns: + Markdown formatted table string. + """ + + def cell(v: Any) -> str: # noqa: ANN401 + return str(v) if v is not None else "" + + lines = [ + "| " + " | ".join(cell(h) for h in headers) + " |", + "| " + " | ".join(["---"] * len(headers)) + " |", + ] + lines.extend("| " + " | ".join(cell(v) for v in row) + " |" for row in rows) + return "\n".join(lines) + + +def _retry_on_auth_failure(func: Callable[P, R]) -> Callable[P, R]: + """Decorator that retries once on authentication failure. + + If an UnauthorizedException is raised (e.g., expired token), this decorator: + 1. Removes the cached token file + 2. Clears the cached API client instances + 3. Retries the operation once + + This handles the case where the cached token has expired and needs refresh. + + Args: + func: The function to wrap. + + Returns: + The wrapped function that handles auth failures gracefully. + """ + + @wraps(func) + def wrapper(*args: P.args, **kwargs: P.kwargs) -> R: + try: + return func(*args, **kwargs) + except UnauthorizedException: + logger.debug("Authentication failed, clearing token and retrying") + remove_cached_token() + _clear_client_cache() + return func(*args, **kwargs) + + return wrapper + + +def _resolve_run_id(client: Client, identifier: str) -> str: + """Resolve a run_id or external_id to a run_id. + + Accepts either: + - A run_id (UUID) - used directly + - An external_id (item identifier) - finds the run containing that item + + Args: + client: Authenticated platform client. + identifier: Either a run_id or an external_id. + + Returns: + The resolved run_id. + + Raises: + NotFoundException: If no matching run is found. + """ + # First, try to use it directly as a run_id + try: + run = client.runs(identifier) + run.details() # Validate it exists + return identifier + except NotFoundException: + pass + + # Not a valid run_id, try to find a run by external_id + runs = list(client.runs.list(external_id=identifier, page_size=1)) + if runs: + return runs[0].run_id + + # No match found + msg = f"No run found with run_id or external_id: {identifier}" + raise NotFoundException(msg) + + +# ============================================================================= +# MCP Tools +# ============================================================================= + + +@mcp.tool() +@_retry_on_auth_failure +def list_runs( + limit: int = 10, + app_id: str | None = None, +) -> str: + """List recent application runs. + + Args: + limit: Maximum number of runs to return (default 10). + app_id: Optional application ID to filter by. + + Returns: + Markdown table of runs with ID, application, version, state, and item counts. + """ + client = Client() + + runs_iter = client.runs.list(application_id=app_id) if app_id else client.runs.list() + runs = list(islice(runs_iter, limit)) + + if not runs: + return "No runs found." + + headers = ["Run ID", "Application", "Version", "State", "Items"] + rows = [] + for run in runs: + details = run.details() + stats = details.statistics + rows.append([ + run.run_id, + details.application_id, + details.version_number, + details.state.value, + f"{stats.item_succeeded_count}/{stats.item_count} succeeded", + ]) + + return _markdown_table(headers, rows) + + +@mcp.tool() +@_retry_on_auth_failure +def get_run_status(run_id: str) -> str: + """Get detailed status of a specific run. + + Args: + run_id: The run ID or external ID (item identifier) to check. + + Returns: + Detailed status including state, statistics, and any errors. + """ + client = Client() + + try: + resolved_id = _resolve_run_id(client, run_id) + run = client.runs(resolved_id) + details = run.details() + except NotFoundException: + return f"Run not found: {run_id}" + + lines = [ + f"## Run Status: {resolved_id}", + "", + f"- **Application:** {details.application_id}", + f"- **Version:** {details.version_number}", + f"- **State:** {details.state.value}", + ] + + if details.termination_reason: + lines.append(f"- **Termination Reason:** {details.termination_reason.value}") + if details.error_message: + lines.append(f"- **Error:** {details.error_message}") + + if details.statistics: + stats = details.statistics + lines.extend([ + "", + "### Item Statistics", + f"- Total: {stats.item_count}", + f"- Succeeded: {stats.item_succeeded_count}", + f"- Processing: {stats.item_processing_count}", + f"- Pending: {stats.item_pending_count}", + f"- User Errors: {stats.item_user_error_count}", + f"- System Errors: {stats.item_system_error_count}", + f"- Skipped: {stats.item_skipped_count}", + ]) + + return "\n".join(lines) + + +@mcp.tool() +@_retry_on_auth_failure +def get_run_items(run_id: str) -> str: + """Get all items in a run with their states. + + Args: + run_id: The run ID or external ID (item identifier) to get items for. + + Returns: + List of items with their states and any errors. + """ + client = Client() + + try: + resolved_id = _resolve_run_id(client, run_id) + run = client.runs(resolved_id) + items = list(run.results()) + except NotFoundException: + return f"Run not found: {run_id}" + + if not items: + return "No items found in this run." + + headers = ["#", "External ID", "State", "Output", "Error"] + rows = [] + for i, item in enumerate(items, 1): + ext_id = item.external_id + external_id = f"...{ext_id[-EXTERNAL_ID_MAX_LEN:]}" if len(ext_id) > EXTERNAL_ID_MAX_LEN else ext_id + rows.append([i, external_id, item.state.value, item.output.value, item.error_message or ""]) + + return f"## Items in Run: {resolved_id}\n\n{_markdown_table(headers, rows)}" + + +def _download_readouts_impl(run_id: str, output_dir: str | None = None) -> str: + """Internal implementation for downloading readouts. + + Downloads readouts from all items in a run to separate files per item. + Each file includes the item's external_id in the filename to enable + per-slide filtering via DuckDB's filename column. + + Args: + run_id: The run ID or external ID (item identifier) to download readouts for. + output_dir: Optional output directory. Uses cache if not specified. + + Returns: + Summary of downloaded files. + """ + client = Client() + + try: + resolved_id = _resolve_run_id(client, run_id) + run = client.runs(resolved_id) + items = list(run.results()) + except NotFoundException: + return f"Run not found: {run_id}" + + # Clear cached DuckDB connection since we're downloading fresh data + _clear_duckdb_connection(resolved_id) + + downloaded_cells: list[str] = [] + downloaded_slides: list[str] = [] + + for item in items: + if item.output != ItemOutput.FULL: + continue + + for artifact in item.output_artifacts: + if "readout" not in artifact.name: + continue + + # Determine readout type + if "slide" in artifact.name: + readout_type = "slide" + elif "cell" in artifact.name: + readout_type = "cell" + else: + continue + + # Determine output path (include external_id for per-slide files) + if output_dir: + # Sanitize external_id for filename + safe_id = item.external_id.replace("/", "_").replace("\\", "_") + out_path = Path(output_dir) / f"{readout_type}_readouts_{safe_id}.csv" + out_path.parent.mkdir(parents=True, exist_ok=True) + else: + out_path = _get_readout_cache_path(resolved_id, readout_type, item.external_id) + + # Download + if artifact.download_url: + response = requests.get(artifact.download_url, timeout=300) + response.raise_for_status() + out_path.write_bytes(response.content) + size_mb = len(response.content) / (1024 * 1024) + if readout_type == "cell": + downloaded_cells.append(f" - {out_path.name} ({size_mb:.1f} MB)") + else: + downloaded_slides.append(f" - {out_path.name} ({size_mb:.2f} MB)") + + if not downloaded_cells and not downloaded_slides: + return "No readouts found in this run. The run may not have completed successfully." + + lines = ["## Downloaded Readouts", ""] + if downloaded_cells: + lines.append(f"**Cell readouts ({len(downloaded_cells)} files):**") + lines.extend(downloaded_cells) + lines.append("") + if downloaded_slides: + lines.append(f"**Slide readouts ({len(downloaded_slides)} files):**") + lines.extend(downloaded_slides) + + return "\n".join(lines) + + +@mcp.tool() +@_retry_on_auth_failure +def download_readouts(run_id: str, output_dir: str | None = None) -> str: + """Download slide and cell readouts for a run. + + Args: + run_id: The run ID or external ID (item identifier) to download readouts for. + output_dir: Optional output directory. Uses cache if not specified. + + Returns: + Paths to the downloaded files. + """ + return _download_readouts_impl(run_id, output_dir) + + +@mcp.tool() +@_retry_on_auth_failure +def query_readouts_sql(run_id: str, sql: str) -> str: + """Execute an arbitrary SQL query on the readout data. + + This is a powerful tool for complex analysis. The readout tables are available as: + - 'cells' - cell-level data (typically has a large number of rows) + - 'slides' - slide-level data (typically has a larger number of columns) + + CRITICAL: Check schema first to discover exact column names. Do NOT guess. + + Args: + run_id: The run ID or external ID (item identifier) to query readouts for. + sql: SQL query to execute. Use 'slides' and 'cells' as table names. + Example: "SELECT CELL_CLASS, COUNT(*) as n FROM cells GROUP BY CELL_CLASS" + + Returns: + Query results as markdown table, or error message. + """ + client = Client() + try: + resolved_id = _resolve_run_id(client, run_id) + except NotFoundException: + return f"Run not found: {run_id}" + + has_slides, has_cells = _ensure_readouts_exist(resolved_id) + + if not has_slides and not has_cells: + return f"No readouts found for run {run_id}. Download readouts first." + + try: + # Get cached connection with views already configured + con = _get_duckdb_connection(resolved_id) + + # Execute the user's query + result = con.execute(sql) + rows = result.fetchmany(MAX_SQL_RESULT_ROWS) + columns = result.description + + if not rows: + return "Query returned no results." + + truncated = result.fetchone() is not None + headers = [col[0] for col in columns] + suffix = " (truncated)" if truncated else "" + + return f"{_markdown_table(headers, list(rows))}\n\n*{len(rows)} rows{suffix}*" + + except FileNotFoundError: + return f"No readouts found for run {run_id}. Download readouts first." + except Exception as e: + # Provide helpful error with available columns + error_msg = f"SQL Error: {e}\n\n" + + # Use cached schema for error context + cell_schema = _get_schema(resolved_id, "cell") + slide_schema = _get_schema(resolved_id, "slide") + + if cell_schema: + col_names = [c[0] for c in cell_schema[:20]] + error_msg += f"**Available cell columns:** {', '.join(col_names)}...\n" + if slide_schema: + col_names = [c[0] for c in slide_schema[:20]] + error_msg += f"**Available slide columns:** {', '.join(col_names)}...\n" + + return error_msg + + +def _ensure_readouts_exist(resolved_id: str) -> tuple[bool, bool]: + """Ensure readouts exist for a run, downloading if necessary. + + Args: + resolved_id: The resolved run ID. + + Returns: + Tuple of (has_slides, has_cells) booleans. + """ + if not (_has_readout_files(resolved_id, "slide") or _has_readout_files(resolved_id, "cell")): + _download_readouts_impl(resolved_id) + + return _has_readout_files(resolved_id, "slide"), _has_readout_files(resolved_id, "cell") + + +@mcp.tool() +@_retry_on_auth_failure +def get_readout_schema(run_id: str, readout_type: str = "cell") -> str: + """Get the schema (column names and types) of a readout file. + + This tool also populates the global schema cache, making the schema available + via the static MCP resources readouts://schema/cell and readouts://schema/slide. + + Args: + run_id: The run ID or external ID (item identifier) to get schema for. + readout_type: Type of readout ('slide' or 'cell', default 'cell'). + + Returns: + Table schema with column names and types. + """ + client = Client() + try: + resolved_id = _resolve_run_id(client, run_id) + except NotFoundException: + return f"Run not found: {run_id}" + + _ensure_readouts_exist(resolved_id) + + schema = _get_schema(resolved_id, readout_type) + if not schema: + return f"No {readout_type} readouts found for run {run_id}." + + return _format_schema_markdown(schema, readout_type) + + +@mcp.tool() +@_retry_on_auth_failure +def get_current_user() -> str: + """Get information about the currently authenticated user. + + Returns: + User email and organization information. + """ + client = Client() + + try: + me = client.me() + return f"**User:** {me.user.email}\n**Organization:** {me.organization.name}" + except Exception as e: + return f"Not authenticated or error: {e}" + + +# ============================================================================= +# MCP Resources +# ============================================================================= + + +def _format_schema_markdown(schema: list[tuple[str, str]], readout_type: str) -> str: + """Format schema as markdown table. + + Args: + schema: List of (column_name, column_type) tuples. + readout_type: Type of readout ('slide' or 'cell'). + + Returns: + Markdown formatted schema table. + """ + table = _markdown_table(["Column", "Type"], schema) + return f"## {readout_type.title()} Readout Schema\n\n{table}\n\n*Total columns: {len(schema)}*" + + +@mcp.resource("readouts://schema/cell") +def cell_schema_resource() -> str: + """Cell readout schema. + + Provides column names and types for the cells table without requiring + a tool call. Schema is discovered from the first run that downloads readouts + and is identical across all runs. + + Returns: + Markdown table of column names and types, or a helpful message if + no schema has been discovered yet. + """ + if "cell" not in _schema_cache: + return ( + "Cell schema not yet available. Use download_readouts or get_readout_schema " + "with any run_id to discover the schema." + ) + return _format_schema_markdown(_schema_cache["cell"], "cell") + + +@mcp.resource("readouts://schema/slide") +def slide_schema_resource() -> str: + """Slide readout schema. + + Provides column names and types for the slides table without requiring + a tool call. Schema is discovered from the first run that downloads readouts + and is identical across all runs. + + Returns: + Markdown table of column names and types, or a helpful message if + no schema has been discovered yet. + """ + if "slide" not in _schema_cache: + return ( + "Slide schema not yet available. Use download_readouts or get_readout_schema " + "with any run_id to discover the schema." + ) + return _format_schema_markdown(_schema_cache["slide"], "slide") + + +# ============================================================================= +# MCP Apps: Interactive Visualization +# ============================================================================= + + +@mcp.resource( + RESOURCE_PATH, # ui://chart (prefixed when mounted) + ui=CHART_RESOURCE_UI, # Structured UI metadata +) +def chart_view() -> str: + """Interactive chart viewer using Chart.js. + + This resource provides a generic HTML template that can render any Chart.js + configuration. The chart type (bar, pie, histogram, scatter, line) is determined + by the configuration passed from the visualize_readouts tool. + + The UI uses the MCP Apps SDK to: + - Receive chart configuration from tool results + - Render interactive charts with Chart.js + - Support drill-down queries via click handlers + + Returns: + HTML content for the MCP App iframe. + """ + return get_chart_html() + + +@mcp.tool(ui=ToolUI(resource_uri=VIEW_URI)) # pyright: ignore[reportCallIssue] +@_retry_on_auth_failure +def visualize_readouts( + run_id: str, + chart_type: Literal["bar", "pie", "histogram", "scatter", "line"], + sql: str, + title: str | None = None, + x_column: str | None = None, + y_column: str | None = None, + color_column: str | None = None, +) -> ToolResult: + """Generate an interactive visualization from readout data. + + Creates charts that render directly in MCP Apps-compatible clients like + Claude Desktop. The chart is interactive with hover tooltips and optional + drill-down capabilities. Results are automatically limited for performance. + + CRITICAL: Check schema first to discover exact column names. Do NOT guess. + + Chart Types: + - bar: Category comparisons (e.g., cell counts by type) + - pie: Proportional breakdown (e.g., % of cells in each region) + - histogram: Distribution of numeric values (e.g., nucleus area distribution) + - scatter: Spatial or correlation plots (e.g., cell positions by type) + - line: Trends over ordered categories + + Args: + run_id: The run ID or external ID. + chart_type: Type of chart to generate. + sql: SQL query to execute on cells/slides tables. The query should return + data suitable for the chart type: + - bar/pie/line: Two columns (label, value) + - histogram: One numeric column to bin + - scatter: Two or three columns (x, y, optional_color) + title: Optional chart title. + x_column: Column for x-axis/labels. Auto-detected from first column if not specified. + y_column: Column for y-axis/values. Auto-detected from second column if not specified. + color_column: Optional column for color grouping (scatter plots only). + + Returns: + JSON chart configuration for the MCP App UI to render. + + Examples: + # Bar chart of cell types + visualize_readouts( + run_id="abc-123", + chart_type="bar", + sql="SELECT CELL_CLASS, COUNT(*) as count FROM cells GROUP BY CELL_CLASS ORDER BY count DESC", + title="Cell Distribution" + ) + + # Pie chart of tissue regions + visualize_readouts( + run_id="abc-123", + chart_type="pie", + sql="SELECT 'Carcinoma' as region, SUM(CASE WHEN IN_CARCINOMA THEN 1 ELSE 0 END) as count FROM cells + UNION ALL + SELECT 'Stroma', SUM(CASE WHEN IN_STROMA THEN 1 ELSE 0 END) FROM cells", + title="Cells by Tissue Region" + ) + + # Scatter plot of cell positions (auto-limited for performance) + visualize_readouts( + run_id="abc-123", + chart_type="scatter", + sql="SELECT CENTROID_X, CENTROID_Y, CELL_CLASS FROM cells", + title="Cell Spatial Distribution", + color_column="CELL_CLASS" + ) + """ + + # Helper to wrap result in ToolResult with UI metadata + # The meta.ui.resourceUri tells Claude Desktop to render the result with the MCP App + def _chart_result(data: dict[str, Any]) -> ToolResult: + return ToolResult( + content=[types.TextContent(type="text", text=json.dumps(data))], + meta={"ui": {"resourceUri": VIEW_URI}}, + ) + + client = Client() + try: + resolved_id = _resolve_run_id(client, run_id) + except NotFoundException: + return _chart_result({"error": f"Run not found: {run_id}"}) + + has_slides, has_cells = _ensure_readouts_exist(resolved_id) + + if not has_slides and not has_cells: + return _chart_result({"error": f"No readouts found for run {run_id}. Download readouts first."}) + + try: + # Get cached connection with views already configured + con = _get_duckdb_connection(resolved_id) + + # Execute the user's query + result = con.execute(sql) + + # Build chart configuration (automatically limited to MAX_CHART_POINTS) + chart_config = build_chart_from_sql_result( + result=result, + chart_type=chart_type, + title=title, + x_column=x_column, + y_column=y_column, + color_column=color_column, + ) + + return _chart_result(chart_config) + + except FileNotFoundError: + return _chart_result({"error": f"No readouts found for run {run_id}. Download readouts first."}) + except Exception as e: + # Provide helpful error with available columns + error_data: dict[str, str | list[str]] = {"error": f"SQL Error: {e}"} + + cell_schema = _get_schema(resolved_id, "cell") + slide_schema = _get_schema(resolved_id, "slide") + + hints: list[str] = [] + if cell_schema: + col_names = [c[0] for c in cell_schema[:10]] + hints.append(f"Cell columns: {', '.join(col_names)}...") + if slide_schema: + col_names = [c[0] for c in slide_schema[:10]] + hints.append(f"Slide columns: {', '.join(col_names)}...") + + if hints: + error_data["hints"] = hints + + return _chart_result(error_data) diff --git a/src/aignostics/mcp/skills/summarize-cell-readouts/SKILL.md b/src/aignostics/mcp/skills/summarize-cell-readouts/SKILL.md new file mode 100644 index 00000000..8abbff71 --- /dev/null +++ b/src/aignostics/mcp/skills/summarize-cell-readouts/SKILL.md @@ -0,0 +1,217 @@ +--- +name: summarize-cell-readouts +description: | + Analyze and summarize cell-level readout data from Aignostics Platform application runs. + + USE THIS SKILL WHEN THE USER: + - Asks about cell counts, cell numbers, how many cells, total cells + - Wants cell type distributions, cell class breakdowns, or cell statistics + - Asks about tissue regions (carcinoma, stroma, tumor) and cell locations + - Requests cell summaries, cell analysis, or cell composition + - Wants to know what types of cells were detected in a slide or run + - Needs a breakdown of cells by category, class, region, or type + - Wants to analyze HETA (H&E Tissue Analyzer) or pathology results + - Asks: "summarize cells", "what cells", "cell breakdown", "analyze cells" + - Uses words like: cells, count, summary, analyze, breakdown, statistics, distribution + + CRITICAL: Check the schema FIRST to discover exact column names. Do NOT guess. +--- + +# Summarize Cell Readouts + +Analyze cell-level data from an Aignostics Platform run to understand cell distributions, +tissue region breakdowns, and cell type statistics. + +## When to Use This Skill + +Use this skill when the user wants to: +- Get cell counts and statistics from a run +- Understand cell type distributions +- Analyze tissue region membership +- Get a summary or overview of cell-level results +- Compare cell populations across different categories + +## Prerequisites + +You need a run ID. If not provided by the user, find one with: +``` +list_runs() +``` + +## Workflow + +### Step 1: Download Readouts (if needed) + +First, ensure the readout data is downloaded and cached locally: + +``` +download_readouts(run_id="") +``` + +This downloads both slide and cell readouts. The data is cached, so subsequent +queries will be fast. + +### Step 2: Check the Schema (REQUIRED) + +**Always check the schema first** to discover available columns. Column names vary by application version. + +**Option A - Read the MCP resource (preferred, no tool call needed):** +``` +Read resource: readouts://schema/cell +``` + +Note: This static resource works after any run's readouts have been downloaded. +The schema is identical across all runs, so no run_id is needed in the URI. + +**Option B - Use the tool:** +``` +get_readout_schema(run_id="", readout_type="cell") +``` + +Look for: +- A column for cell type/class classification +- Boolean `IN_*` columns for tissue region membership +- Columns for morphological features +- Coordinate columns for cell locations +- `external_id` - The slide identifier (added automatically, enables per-slide filtering) + +### Step 3: List Available Slides (Optional) + +To see which slides are available for analysis: + +``` +query_readouts_sql( + run_id="", + sql="SELECT DISTINCT external_id FROM cells" +) +``` + +This shows all slide identifiers in the run. Users can then filter by specific slides. + +### Step 4: Get Total Cell Count + +For all slides: +``` +query_readouts_sql( + run_id="", + sql="SELECT COUNT(*) as total_cells FROM cells" +) +``` + +For a specific slide (use LIKE for partial matching): +``` +query_readouts_sql( + run_id="", + sql="SELECT COUNT(*) as total_cells FROM cells WHERE external_id LIKE '%slide_name.tiff'" +) +``` + +### Step 5: Cell Distribution by Class + +Get the breakdown of cells by their classification. Use the cell class column name from the schema: + +``` +query_readouts_sql( + run_id="", + sql=""" + SELECT + , + COUNT(*) as count, + ROUND(COUNT(*) * 100.0 / SUM(COUNT(*)) OVER(), 1) as percentage + FROM cells + GROUP BY + ORDER BY count DESC + """ +) +``` + +To filter for a specific slide, add a WHERE clause: +``` +query_readouts_sql( + run_id="", + sql=""" + SELECT + , + COUNT(*) as count, + ROUND(COUNT(*) * 100.0 / SUM(COUNT(*)) OVER(), 1) as percentage + FROM cells + WHERE external_id LIKE '%slide_name%' + GROUP BY + ORDER BY count DESC + """ +) +``` + +### Step 6: Tissue Region Breakdown + +Count cells in each tissue region. Use the `IN_*` column names from the schema: + +``` +query_readouts_sql( + run_id="", + sql=""" + SELECT + SUM(CASE WHEN THEN 1 ELSE 0 END) as region_1, + SUM(CASE WHEN THEN 1 ELSE 0 END) as region_2, + COUNT(*) as total + FROM cells + """ +) +``` + +### Step 7: Cross-tabulation (Optional) + +For deeper analysis, cross-tabulate cell types by tissue region: + +``` +query_readouts_sql( + run_id="", + sql=""" + SELECT + , + SUM(CASE WHEN THEN 1 ELSE 0 END) as region_1, + SUM(CASE WHEN THEN 1 ELSE 0 END) as region_2, + COUNT(*) as total + FROM cells + GROUP BY + ORDER BY total DESC + """ +) +``` + +## Output Format + +Present the results clearly: + +**Cell Summary for Run: ``** + +### Overview +- **Total Cells Analyzed:** X + +### Cell Type Distribution +| Cell Type | Count | Percentage | +|-----------|-------|------------| +| [from query results] | N | X% | +| ... | ... | ... | + +### Tissue Region Distribution +| Region | Cell Count | Percentage | +|--------|------------|------------| +| [from query results] | N | X% | +| ... | ... | ... | + +### Key Findings +- [Highlight notable patterns] +- [Note any unusual distributions] + +## Tips + +- **Schema first**: Always check the schema - column names vary by application version +- **Per-slide filtering**: Use `WHERE external_id LIKE '%partial_name%'` to filter by slide +- **List slides first**: If the user asks about a specific slide, run `SELECT DISTINCT external_id FROM cells` to show available slides +- **Boolean columns**: The `IN_*` columns are boolean. Use `SUM(CASE WHEN col THEN 1 ELSE 0 END)` to count +- **Caching**: After the first query, subsequent queries are fast due to connection caching +- **Large datasets**: Cell readouts can have millions of rows. Percentages help understand proportions +- **NULL handling**: Some cells may have NULL values for certain columns - consider filtering or handling +- **Combine queries**: You can run multiple SQL queries efficiently thanks to connection caching +- **Path sanitization**: Path separators (`/`, `\`) in external_id are converted to underscores in storage diff --git a/src/aignostics/mcp/skills/visualize-readouts/SKILL.md b/src/aignostics/mcp/skills/visualize-readouts/SKILL.md new file mode 100644 index 00000000..91c89e1d --- /dev/null +++ b/src/aignostics/mcp/skills/visualize-readouts/SKILL.md @@ -0,0 +1,282 @@ +--- +name: visualize-readouts +description: | + Create interactive charts and visualizations from readout data (cells and slides). + + USE THIS SKILL WHEN THE USER: + - Asks to visualize, chart, plot, graph, or display readout data + - Wants a bar chart, pie chart, histogram, scatter plot, or line chart + - Asks for cell positions, cell locations, spatial distribution, or cell map + - Wants to see where cells are located on a slide + - Asks to plot cells, show cells, or map cells + - Requests cell type distribution or cell class breakdown visually + - Wants to compare categories visually (e.g., "compare cell types") + - Asks for distribution plots of numeric features (area, size, etc.) + - Uses words like: distribution, scatter, plot, chart, graph, visualize, display, show me, map + + CRITICAL: Check the schema FIRST to discover exact column names. Do NOT guess. +--- + +# Visualize Readouts + +Create interactive charts from readout data using the `visualize_readouts` tool. +Charts render directly in the conversation with hover tooltips and click interactions. + +## When to Use This Skill + +Use this skill when the user wants to: +- Visualize cell or slide data distributions (bar chart, pie chart) +- See spatial distribution of cells (scatter plot) +- Understand numeric feature distributions (histogram) +- Track trends across categories or slides (line chart) +- Create any visual representation of readout data + +## Prerequisites + +You need a run ID. If not provided by the user, find one with: +``` +list_runs() +``` + +## Available Chart Types + +| Chart Type | Best For | Example Use Case | +|------------|----------|------------------| +| **bar** | Comparing categories | Cell counts by type, slide metrics | +| **pie** | Showing proportions | % of cells in each tissue region | +| **histogram** | Numeric distributions | Nucleus area, slide area distributions | +| **scatter** | Spatial/correlation data | Cell positions, feature correlations | +| **line** | Ordered trends | Counts across slides, ordered categories | + +## Available Tables + +The `visualize_readouts` tool queries two tables: + +| Table | Description | Typical Columns | +|-------|-------------|-----------------| +| `cells` | Cell-level data (many rows per slide) | CELL_CLASS, CENTROID_X, CENTROID_Y, IN_CARCINOMA, etc. | +| `slides` | Slide-level data (one row per slide) | SLIDE_ID, TOTAL_CELLS, AREA_MM2, etc. | + +Both tables have an `external_id` column for per-slide filtering. + +## Workflow + +### Step 1: Check the Schema (REQUIRED) + +Always check the schema first to discover available columns. + +**Option A - Read the MCP resources (preferred, no tool call needed):** +``` +Read resource: readouts://schema/cell +Read resource: readouts://schema/slide +``` + +Note: These static resources work after any run's readouts have been downloaded. +The schema is identical across all runs, so no run_id is needed in the URI. + +**Option B - Use the tool:** +``` +get_readout_schema(run_id="", readout_type="cell") +get_readout_schema(run_id="", readout_type="slide") +``` + +Look for: +- Classification columns (e.g., `CELL_CLASS`, `cell_type`) +- Boolean columns (e.g., `IN_CARCINOMA`, `IN_STROMA`) +- Coordinate columns (e.g., `CENTROID_X`, `CENTROID_Y`) +- Numeric feature columns (e.g., `nucleus_area`, `AREA_MM2`) + +### Step 2: Create the Visualization + +Use the `visualize_readouts` tool with an appropriate SQL query. + +#### Bar Chart: Category Distribution + +**Cell types:** +``` +visualize_readouts( + run_id="", + chart_type="bar", + sql="SELECT CELL_CLASS, COUNT(*) as count FROM cells GROUP BY CELL_CLASS ORDER BY count DESC", + title="Cell Distribution by Type" +) +``` + +**Slide metrics:** +``` +visualize_readouts( + run_id="", + chart_type="bar", + sql="SELECT external_id, TOTAL_CELLS FROM slides ORDER BY TOTAL_CELLS DESC", + title="Total Cells per Slide" +) +``` + +#### Pie Chart: Proportional Breakdown + +**Tissue regions:** +``` +visualize_readouts( + run_id="", + chart_type="pie", + sql=""" + SELECT 'Carcinoma' as region, SUM(CASE WHEN IN_CARCINOMA THEN 1 ELSE 0 END) as cells FROM cells + UNION ALL + SELECT 'Stroma', SUM(CASE WHEN IN_STROMA THEN 1 ELSE 0 END) FROM cells + UNION ALL + SELECT 'Other', SUM(CASE WHEN NOT IN_CARCINOMA AND NOT IN_STROMA THEN 1 ELSE 0 END) FROM cells + """, + title="Cells by Tissue Region" +) +``` + +#### Histogram: Feature Distribution + +**Cell feature:** +``` +visualize_readouts( + run_id="", + chart_type="histogram", + sql="SELECT nucleus_area FROM cells WHERE nucleus_area IS NOT NULL", + title="Nucleus Area Distribution" +) +``` + +**Slide feature:** +``` +visualize_readouts( + run_id="", + chart_type="histogram", + sql="SELECT AREA_MM2 FROM slides WHERE AREA_MM2 IS NOT NULL", + title="Slide Area Distribution" +) +``` + +#### Scatter Plot: Spatial or Correlation Data + +**Cell spatial distribution:** +``` +visualize_readouts( + run_id="", + chart_type="scatter", + sql="SELECT CENTROID_X, CENTROID_Y, CELL_CLASS FROM cells", + title="Cell Spatial Distribution", + color_column="CELL_CLASS" +) +``` + +**Feature correlation:** +``` +visualize_readouts( + run_id="", + chart_type="scatter", + sql="SELECT nucleus_area, cytoplasm_area FROM cells WHERE nucleus_area IS NOT NULL", + title="Nucleus vs Cytoplasm Area", + x_column="nucleus_area", + y_column="cytoplasm_area" +) +``` + +Note: Charts are automatically limited to 5,000 points for performance. If truncated, the response will indicate this. + +#### Line Chart: Trends + +**Cells per slide:** +``` +visualize_readouts( + run_id="", + chart_type="line", + sql="SELECT external_id, COUNT(*) as cells FROM cells GROUP BY external_id ORDER BY external_id", + title="Cell Counts per Slide" +) +``` + +### Step 3: Per-Slide Filtering (Optional) + +To visualize data from a specific slide: + +``` +visualize_readouts( + run_id="", + chart_type="bar", + sql=""" + SELECT CELL_CLASS, COUNT(*) as count + FROM cells + WHERE external_id LIKE '%slide001.tiff' + GROUP BY CELL_CLASS + ORDER BY count DESC + """, + title="Cell Distribution - Slide 001" +) +``` + +## Tips + +- **Schema first**: Always check the schema - column names vary by application version +- **Automatic limits**: Charts are automatically limited to 5,000 points for performance (truncation is indicated in response) +- **Use aggregations**: For bar/pie/line charts, use `GROUP BY` and `COUNT(*)` or `SUM()` +- **Handle NULLs**: Filter out NULL values for histograms: `WHERE column IS NOT NULL` +- **Color by category**: For scatter plots, use `color_column` to color points by a categorical column +- **Combine with queries**: Use `query_readouts_sql` first to explore data, then visualize +- **Drill-down**: Clicking chart elements (bar/pie) can trigger follow-up queries in supported clients +- **Both tables**: You can join cells and slides tables if needed for complex analysis + +## Example Conversations + +**User**: "Show me a chart of cell types in my run" + +``` +visualize_readouts( + run_id="abc-123", + chart_type="bar", + sql="SELECT CELL_CLASS, COUNT(*) as count FROM cells GROUP BY CELL_CLASS ORDER BY count DESC", + title="Cell Type Distribution" +) +``` + +**User**: "Plot the spatial positions of cells colored by type" + +``` +visualize_readouts( + run_id="abc-123", + chart_type="scatter", + sql="SELECT CENTROID_X, CENTROID_Y, CELL_CLASS FROM cells", + title="Cell Spatial Distribution", + color_column="CELL_CLASS" +) +``` + +**User**: "Compare cell counts across slides" + +``` +visualize_readouts( + run_id="abc-123", + chart_type="bar", + sql="SELECT external_id, COUNT(*) as cells FROM cells GROUP BY external_id ORDER BY cells DESC", + title="Cells per Slide" +) +``` + +**User**: "What does the slide area distribution look like?" + +``` +visualize_readouts( + run_id="abc-123", + chart_type="histogram", + sql="SELECT AREA_MM2 FROM slides WHERE AREA_MM2 IS NOT NULL", + title="Slide Area Distribution" +) +``` + +## How It Works + +The `visualize_readouts` tool uses MCP Apps to render interactive charts: + +1. Your SQL query runs via DuckDB on the local readout CSV files +2. Results are automatically limited to 5,000 points for performance +3. The tool returns a Chart.js configuration as JSON (with `_meta.truncated: true` if data was limited) +4. The MCP App UI (served as `ui://aignostics-platform/chart`) receives the configuration +5. Chart.js renders the interactive chart in a sandboxed iframe +6. Users can hover for tooltips and click for drill-down queries + +This approach supports all chart types with a single tool and UI resource. If the response includes `_meta.truncated: true`, inform the user that the visualization shows a subset of the data. diff --git a/src/aignostics/utils/__init__.py b/src/aignostics/utils/__init__.py index 309e9bb2..442400e4 100644 --- a/src/aignostics/utils/__init__.py +++ b/src/aignostics/utils/__init__.py @@ -26,7 +26,14 @@ from ._fs import get_user_data_directory, open_user_data_directory, sanitize_path, sanitize_path_component from ._health import Health from ._log import LogSettings -from ._mcp import MCP_SERVER_NAME, MCP_TRANSPORT, mcp_create_server, mcp_discover_servers, mcp_list_tools, mcp_run +from ._mcp import ( + MCP_SERVER_NAME, + MCP_TRANSPORT, + mcp_create_server, + mcp_discover_servers, + mcp_list_tools, + mcp_run_server, +) from ._nav import BaseNavBuilder, NavGroup, NavItem, gui_get_nav_groups from ._process import SUBPROCESS_CREATION_FLAGS, ProcessInfo, get_process_info from ._service import BaseService @@ -78,7 +85,7 @@ "mcp_create_server", "mcp_discover_servers", "mcp_list_tools", - "mcp_run", + "mcp_run_server", "open_user_data_directory", "prepare_cli", "sanitize_path", diff --git a/src/aignostics/utils/_mcp.py b/src/aignostics/utils/_mcp.py index 4c160f37..db52d82d 100644 --- a/src/aignostics/utils/_mcp.py +++ b/src/aignostics/utils/_mcp.py @@ -13,10 +13,10 @@ uv run aignostics mcp list-tools # Use programmatically - from aignostics.utils import mcp_create_server, mcp_run, mcp_list_tools + from aignostics.utils import mcp_create_server, mcp_run_server, mcp_list_tools server = mcp_create_server() - mcp_run() + mcp_run_server() """ from __future__ import annotations @@ -42,9 +42,9 @@ def mcp_discover_servers() -> list[FastMCP]: - Searches all registered plugin packages via entry points Returns: - list[FastMCP]: List of discovered FastMCP server instances. + list: List of discovered FastMCP server instances. """ - servers = locate_implementations(FastMCP) + servers: list[FastMCP] = locate_implementations(FastMCP) logger.debug(f"Discovered {len(servers)} MCP servers") return servers @@ -64,7 +64,7 @@ def mcp_create_server(server_name: str = MCP_SERVER_NAME) -> FastMCP: """ mcp = FastMCP(name=server_name, version=__version__) - # Mount discovered servers + # Mount all discovered FastMCP servers servers = mcp_discover_servers() seen_names: set[str] = set() count = 0 @@ -76,14 +76,14 @@ def mcp_create_server(server_name: str = MCP_SERVER_NAME) -> FastMCP: continue seen_names.add(server.name) logger.info(f"Mounting MCP server: {server.name}") - mcp.mount(server, prefix=server.name) + mcp.mount(server, namespace=server.name) count += 1 logger.info(f"Mounted {count} MCP servers") return mcp -def mcp_run(server_name: str = MCP_SERVER_NAME) -> None: +def mcp_run_server(server_name: str = MCP_SERVER_NAME) -> None: """Run the MCP server using stdio transport. Starts an MCP server that exposes SDK functionality to AI agents. @@ -115,7 +115,7 @@ def mcp_list_tools(server_name: str = MCP_SERVER_NAME) -> list[dict[str, Any]]: 'name' and 'description' keys. """ server = mcp_create_server(server_name) - # FastMCP's get_tools() is async because mounted servers may need to + # FastMCP's list_tools() is async because mounted servers may need to # lazily initialize resources. We use asyncio.run() to bridge sync/async. - tools = asyncio.run(server.get_tools()) - return [{"name": name, "description": tool.description or ""} for name, tool in tools.items()] + tools = asyncio.run(server.list_tools()) + return [{"name": tool.name, "description": tool.description or ""} for tool in tools] diff --git a/tests/aignostics/cli_test.py b/tests/aignostics/cli_test.py index b2396e25..070e45f2 100644 --- a/tests/aignostics/cli_test.py +++ b/tests/aignostics/cli_test.py @@ -289,7 +289,7 @@ def mock_uvicorn_run(app, host=None, port=None): # ============================================================================= PATCH_MCP_LOCATE_IMPLEMENTATIONS = "aignostics.utils._mcp.locate_implementations" -PATCH_RUN = "aignostics.utils.mcp_run" +PATCH_RUN = "aignostics.utils.mcp_run_server" @pytest.mark.unit diff --git a/tests/aignostics/mcp/server_test.py b/tests/aignostics/mcp/server_test.py new file mode 100644 index 00000000..fe38edc5 --- /dev/null +++ b/tests/aignostics/mcp/server_test.py @@ -0,0 +1,1151 @@ +"""Unit tests for MCP server tools and helpers.""" + +# ruff: noqa: PLR6301 + +from __future__ import annotations + +from pathlib import Path +from typing import TYPE_CHECKING +from unittest.mock import MagicMock, patch + +import pytest + +from aignostics.mcp._server import ( + DEFAULT_CACHE_DIR, + _clear_duckdb_connection, + _duckdb_connections, + _ensure_readouts_exist, + _extract_external_id_from_filename, + _format_schema_markdown, + _get_cache_dir, + _get_duckdb_connection, + _get_readout_cache_path, + _get_readouts_dir, + _get_schema, + _resolve_run_id, + _schema_cache, +) + +if TYPE_CHECKING: + from collections.abc import Generator + + +# ============================================================================= +# Fixtures +# ============================================================================= + + +@pytest.fixture +def clean_caches() -> Generator[None, None, None]: + """Clear all caches before and after test.""" + _duckdb_connections.clear() + _schema_cache.clear() + yield + _duckdb_connections.clear() + _schema_cache.clear() + + +@pytest.fixture +def mock_readouts_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + """Set up a temporary readouts directory.""" + readouts_dir = tmp_path / "readouts" + readouts_dir.mkdir() + monkeypatch.setenv("AIGNOSTICS_MCP_READOUTS_DIR", str(readouts_dir)) + return readouts_dir + + +@pytest.fixture +def sample_csv_content() -> str: + """Sample CSV content for testing.""" + return ( + "# HETA Readout v1.0\n" # Header line to skip + "CELL_CLASS,CENTROID_X,CENTROID_Y,IN_CARCINOMA,IN_STROMA\n" + "Lymphocyte,100.5,200.3,true,false\n" + "Carcinoma cell,150.2,250.1,true,false\n" + "Fibroblast,300.0,400.0,false,true\n" + ) + + +@pytest.fixture +def run_with_readouts(mock_readouts_dir: Path, sample_csv_content: str, clean_caches: None) -> str: + """Create a run directory with sample readout files using per-slide naming.""" + run_id = "test-run-123" + run_dir = mock_readouts_dir / run_id + run_dir.mkdir() + + # Create cell readouts with per-slide naming convention + cell_file = run_dir / "cell_readouts_slide001.tiff.csv" + cell_file.write_text(sample_csv_content) + + # Create slide readouts with per-slide naming convention + slide_content = "# Slide Readout v1.0\nSLIDE_ID,TOTAL_CELLS,AREA_MM2\nslide-001,1000,25.5\n" + slide_file = run_dir / "slide_readouts_slide001.tiff.csv" + slide_file.write_text(slide_content) + + return run_id + + +# ============================================================================= +# Tests: Configuration Functions +# ============================================================================= + + +@pytest.mark.unit +class TestConfigurationFunctions: + """Tests for configuration helper functions.""" + + def test_get_readouts_dir_default(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Test default readouts directory.""" + monkeypatch.delenv("AIGNOSTICS_MCP_READOUTS_DIR", raising=False) + assert _get_readouts_dir() == DEFAULT_CACHE_DIR + + def test_get_readouts_dir_from_env(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Test readouts directory from environment variable.""" + monkeypatch.setenv("AIGNOSTICS_MCP_READOUTS_DIR", "/custom/path") + assert _get_readouts_dir() == Path("/custom/path") + + def test_get_cache_dir_creates_directory(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Test that get_cache_dir creates the directory if it doesn't exist.""" + new_dir = tmp_path / "new_cache_dir" + monkeypatch.setenv("AIGNOSTICS_MCP_READOUTS_DIR", str(new_dir)) + assert not new_dir.exists() + result = _get_cache_dir() + assert result == new_dir + assert new_dir.exists() + + def test_get_readout_cache_path(self, mock_readouts_dir: Path) -> None: + """Test readout cache path generation with external_id.""" + path = _get_readout_cache_path("run-123", "cell", "slide001.tiff") + assert path == mock_readouts_dir / "run-123" / "cell_readouts_slide001.tiff.csv" + + def test_get_readout_cache_path_creates_run_dir(self, mock_readouts_dir: Path) -> None: + """Test that get_readout_cache_path creates the run directory.""" + run_dir = mock_readouts_dir / "new-run" + assert not run_dir.exists() + _get_readout_cache_path("new-run", "slide", "slide001.tiff") + assert run_dir.exists() + + def test_extract_external_id_from_filename(self) -> None: + """Test extracting external_id from readout filename.""" + # Simple case + assert _extract_external_id_from_filename("cell_readouts_slide001.tiff.csv", "cell") == "slide001.tiff" + assert _extract_external_id_from_filename("slide_readouts_slide001.tiff.csv", "slide") == "slide001.tiff" + + # With sanitized path separators (slashes become underscores) + assert _extract_external_id_from_filename("cell_readouts_a_b_c_slide.tiff.csv", "cell") == "a_b_c_slide.tiff" + + # Complex filename + assert ( + _extract_external_id_from_filename("cell_readouts_my_complex_slide_name.svs.csv", "cell") + == "my_complex_slide_name.svs" + ) + + +# ============================================================================= +# Tests: DuckDB Connection Caching +# ============================================================================= + + +@pytest.mark.unit +class TestDuckDBConnectionCaching: + """Tests for DuckDB connection caching.""" + + def test_get_connection_creates_new(self, run_with_readouts: str) -> None: + """Test that a new connection is created when not cached.""" + assert run_with_readouts not in _duckdb_connections + con = _get_duckdb_connection(run_with_readouts) + assert con is not None + assert run_with_readouts in _duckdb_connections + + def test_get_connection_returns_cached(self, run_with_readouts: str) -> None: + """Test that the same connection is returned from cache.""" + con1 = _get_duckdb_connection(run_with_readouts) + con2 = _get_duckdb_connection(run_with_readouts) + assert con1 is con2 + + def test_get_connection_creates_views(self, run_with_readouts: str) -> None: + """Test that views are created for slides and cells tables.""" + con = _get_duckdb_connection(run_with_readouts) + + # Query the cells view + result = con.execute("SELECT COUNT(*) FROM cells").fetchone() + assert result is not None + assert result[0] == 3 # 3 cells in sample data + + # Query the slides view + result = con.execute("SELECT COUNT(*) FROM slides").fetchone() + assert result is not None + assert result[0] == 1 # 1 slide in sample data + + def test_get_connection_adds_external_id_column(self, run_with_readouts: str) -> None: + """Test that external_id column is added to views for per-slide filtering.""" + con = _get_duckdb_connection(run_with_readouts) + + # Query the external_id column from cells + result = con.execute("SELECT DISTINCT external_id FROM cells").fetchall() + assert result is not None + assert len(result) == 1 # 1 slide in test data + assert result[0][0] == "slide001.tiff" # Extracted from filename + + # Query the external_id column from slides + result = con.execute("SELECT DISTINCT external_id FROM slides").fetchall() + assert result is not None + assert len(result) == 1 + assert result[0][0] == "slide001.tiff" + + def test_external_id_filtering_with_like(self, run_with_readouts: str) -> None: + """Test that external_id can be used for filtering with LIKE.""" + con = _get_duckdb_connection(run_with_readouts) + + # Filter with exact match + result = con.execute("SELECT COUNT(*) FROM cells WHERE external_id = 'slide001.tiff'").fetchone() + assert result is not None + assert result[0] == 3 # All 3 cells match + + # Filter with partial match (LIKE) + result = con.execute("SELECT COUNT(*) FROM cells WHERE external_id LIKE '%slide001%'").fetchone() + assert result is not None + assert result[0] == 3 + + # Filter with no match + result = con.execute("SELECT COUNT(*) FROM cells WHERE external_id = 'nonexistent.tiff'").fetchone() + assert result is not None + assert result[0] == 0 + + def test_get_connection_no_files_raises(self, mock_readouts_dir: Path, clean_caches: None) -> None: + """Test that FileNotFoundError is raised when no readout files exist.""" + run_dir = mock_readouts_dir / "empty-run" + run_dir.mkdir() + + with pytest.raises(FileNotFoundError, match="No readout files found"): + _get_duckdb_connection("empty-run") + + def test_clear_connection_removes_from_cache(self, run_with_readouts: str) -> None: + """Test that clear_duckdb_connection removes the connection from cache.""" + _get_duckdb_connection(run_with_readouts) + assert run_with_readouts in _duckdb_connections + + _clear_duckdb_connection(run_with_readouts) + assert run_with_readouts not in _duckdb_connections + + def test_clear_connection_preserves_schema_cache(self, run_with_readouts: str) -> None: + """Test that clear_duckdb_connection does NOT clear schema cache (schema is global).""" + # Populate schema cache + _get_schema(run_with_readouts, "cell") + _get_schema(run_with_readouts, "slide") + assert "cell" in _schema_cache + assert "slide" in _schema_cache + + _clear_duckdb_connection(run_with_readouts) + + # Schema cache should be preserved since it's global across all runs + assert "cell" in _schema_cache + assert "slide" in _schema_cache + + +# ============================================================================= +# Tests: Schema Caching +# ============================================================================= + + +@pytest.mark.unit +class TestSchemaCaching: + """Tests for schema caching.""" + + def test_get_schema_returns_columns(self, run_with_readouts: str) -> None: + """Test that schema returns column names and types.""" + schema = _get_schema(run_with_readouts, "cell") + # 5 data columns + 1 external_id column from UNION ALL + assert len(schema) == 6 # CELL_CLASS, CENTROID_X, CENTROID_Y, IN_CARCINOMA, IN_STROMA, external_id + + col_names = [col[0] for col in schema] + assert "CELL_CLASS" in col_names + assert "CENTROID_X" in col_names + assert "IN_CARCINOMA" in col_names + assert "external_id" in col_names # Added by UNION ALL query for per-slide filtering + + def test_get_schema_caches_result(self, run_with_readouts: str) -> None: + """Test that schema is cached globally by type after first call.""" + cache_key = "cell" + assert cache_key not in _schema_cache + + schema1 = _get_schema(run_with_readouts, "cell") + assert cache_key in _schema_cache + + schema2 = _get_schema(run_with_readouts, "cell") + assert schema1 is schema2 # Same object from cache + + def test_get_schema_no_file_returns_empty(self, mock_readouts_dir: Path, clean_caches: None) -> None: + """Test that empty list is returned when file doesn't exist.""" + run_dir = mock_readouts_dir / "partial-run" + run_dir.mkdir() + + schema = _get_schema("partial-run", "cell") + assert schema == [] + + +# ============================================================================= +# Tests: Helper Functions +# ============================================================================= + + +@pytest.mark.unit +class TestHelperFunctions: + """Tests for helper functions.""" + + def test_markdown_table_basic(self) -> None: + """Test markdown table formatting with basic data.""" + from aignostics.mcp._server import _markdown_table + + headers = ["Name", "Value"] + rows = [("foo", 1), ("bar", 2)] + result = _markdown_table(headers, rows) + + assert "| Name | Value |" in result + assert "| --- | --- |" in result + assert "| foo | 1 |" in result + assert "| bar | 2 |" in result + + def test_markdown_table_handles_none(self) -> None: + """Test markdown table handles None values.""" + from aignostics.mcp._server import _markdown_table + + headers = ["A", "B"] + rows = [("x", None), (None, "y")] + result = _markdown_table(headers, rows) + + assert "| x | |" in result + assert "| | y |" in result + + def test_markdown_table_empty_rows(self) -> None: + """Test markdown table with no rows.""" + from aignostics.mcp._server import _markdown_table + + headers = ["Col1", "Col2"] + rows: list[tuple[str, str]] = [] + result = _markdown_table(headers, rows) + + assert "| Col1 | Col2 |" in result + assert "| --- | --- |" in result + # Should only have header and separator lines + assert result.count("\n") == 1 + + def test_format_schema_markdown(self) -> None: + """Test schema markdown formatting.""" + schema = [("col1", "VARCHAR"), ("col2", "INTEGER")] + result = _format_schema_markdown(schema, "cell") + + assert "## Cell Readout Schema" in result + assert "| Column | Type |" in result + assert "| col1 | VARCHAR |" in result + assert "| col2 | INTEGER |" in result + assert "Total columns: 2" in result + + def test_ensure_readouts_exist_when_present(self, run_with_readouts: str) -> None: + """Test ensure_readouts_exist returns True when files exist.""" + has_slides, has_cells = _ensure_readouts_exist(run_with_readouts) + assert has_slides is True + assert has_cells is True + + +# ============================================================================= +# Tests: Run ID Resolution +# ============================================================================= + + +@pytest.mark.unit +class TestResolveRunId: + """Tests for run ID resolution.""" + + def test_resolve_run_id_direct_match(self) -> None: + """Test resolving a valid run_id directly.""" + mock_client = MagicMock() + mock_run = MagicMock() + mock_client.runs.return_value = mock_run + + result = _resolve_run_id(mock_client, "run-uuid-123") + + assert result == "run-uuid-123" + mock_client.runs.assert_called_once_with("run-uuid-123") + mock_run.details.assert_called_once() + + def test_resolve_run_id_by_external_id(self) -> None: + """Test resolving via external_id when run_id not found.""" + from aignx.codegen.exceptions import NotFoundException + + mock_client = MagicMock() + mock_run = MagicMock() + mock_run.details.side_effect = NotFoundException("Not found") + mock_client.runs.return_value = mock_run + + # external_id search returns a result + mock_list_result = MagicMock() + mock_list_result.run_id = "resolved-run-id" + mock_client.runs.list.return_value = iter([mock_list_result]) + + result = _resolve_run_id(mock_client, "external-123") + + assert result == "resolved-run-id" + mock_client.runs.list.assert_called_once_with(external_id="external-123", page_size=1) + + def test_resolve_run_id_not_found_raises(self) -> None: + """Test that NotFoundException is raised when no match found.""" + from aignx.codegen.exceptions import NotFoundException + + mock_client = MagicMock() + mock_run = MagicMock() + mock_run.details.side_effect = NotFoundException("Not found") + mock_client.runs.return_value = mock_run + mock_client.runs.list.return_value = iter([]) # No results + + with pytest.raises(NotFoundException, match="No run found"): + _resolve_run_id(mock_client, "nonexistent") + + +# ============================================================================= +# Tests: MCP Tools (Integration with Mocks) +# ============================================================================= + + +@pytest.mark.unit +class TestMCPTools: + """Tests for MCP tool functions. + + Note: MCP tools decorated with @mcp.tool() become FunctionTool objects. + We call the decorated functions directly. + """ + + def test_list_runs_returns_markdown_table(self) -> None: + """Test that list_runs returns a properly formatted markdown table.""" + from aignostics.mcp._server import list_runs + + mock_run = MagicMock() + mock_details = MagicMock() + mock_details.application_id = "heta" + mock_details.version_number = "1.0.0" + mock_details.state.value = "TERMINATED" + mock_details.statistics.item_succeeded_count = 5 + mock_details.statistics.item_count = 5 + mock_run.run_id = "run-123" + mock_run.details.return_value = mock_details + + with patch("aignostics.mcp._server.Client") as mock_get_client: + mock_client = MagicMock() + mock_client.runs.list.return_value = iter([mock_run]) + mock_get_client.return_value = mock_client + + # Call the decorated function directly + result = list_runs(limit=1) + + assert "| Run ID |" in result + assert "run-123" in result + assert "heta" in result + assert "TERMINATED" in result + + def test_list_runs_no_runs(self) -> None: + """Test list_runs when no runs exist.""" + from aignostics.mcp._server import list_runs + + with patch("aignostics.mcp._server.Client") as mock_get_client: + mock_client = MagicMock() + mock_client.runs.list.return_value = iter([]) + mock_get_client.return_value = mock_client + + result = list_runs() + + assert result == "No runs found." + + def test_get_run_status_returns_details(self) -> None: + """Test that get_run_status returns detailed information.""" + from aignostics.mcp._server import get_run_status + + mock_details = MagicMock() + mock_details.application_id = "heta" + mock_details.version_number = "2.0.0" + mock_details.state.value = "PROCESSING" + mock_details.termination_reason = None + mock_details.error_message = None + mock_details.statistics.item_count = 10 + mock_details.statistics.item_succeeded_count = 5 + mock_details.statistics.item_processing_count = 3 + mock_details.statistics.item_pending_count = 2 + mock_details.statistics.item_user_error_count = 0 + mock_details.statistics.item_system_error_count = 0 + mock_details.statistics.item_skipped_count = 0 + + with ( + patch("aignostics.mcp._server.Client") as mock_get_client, + patch("aignostics.mcp._server._resolve_run_id") as mock_resolve, + ): + mock_resolve.return_value = "run-456" + mock_client = MagicMock() + mock_run = MagicMock() + mock_run.details.return_value = mock_details + mock_client.runs.return_value = mock_run + mock_get_client.return_value = mock_client + + result = get_run_status("run-456") + + assert "## Run Status: run-456" in result + assert "**Application:** heta" in result + assert "**State:** PROCESSING" in result + assert "Total: 10" in result + + def test_get_current_user_returns_info(self) -> None: + """Test that get_current_user returns user and org info.""" + from aignostics.mcp._server import get_current_user + + with patch("aignostics.mcp._server.Client") as mock_get_client: + mock_client = MagicMock() + mock_me = MagicMock() + mock_me.user.email = "test@example.com" + mock_me.organization.name = "Test Org" + mock_client.me.return_value = mock_me + mock_get_client.return_value = mock_client + + result = get_current_user() + + assert "test@example.com" in result + assert "Test Org" in result + + def test_query_readouts_sql_executes_query(self, run_with_readouts: str) -> None: + """Test that query_readouts_sql executes SQL and returns results.""" + from aignostics.mcp._server import query_readouts_sql + + with ( + patch("aignostics.mcp._server.Client") as mock_get_client, + patch("aignostics.mcp._server._resolve_run_id") as mock_resolve, + ): + mock_resolve.return_value = run_with_readouts + mock_client = MagicMock() + mock_get_client.return_value = mock_client + + result = query_readouts_sql( + run_with_readouts, "SELECT CELL_CLASS, COUNT(*) as n FROM cells GROUP BY CELL_CLASS ORDER BY n DESC" + ) + + assert "| CELL_CLASS |" in result + assert "Lymphocyte" in result + + def test_query_readouts_sql_truncation_indicator(self, mock_readouts_dir: Path, clean_caches: None) -> None: + """Test that query_readouts_sql shows truncation indicator when results exceed limit.""" + from aignostics.mcp._server import MAX_SQL_RESULT_ROWS, query_readouts_sql + + # Create a run with many rows (more than MAX_SQL_RESULT_ROWS) + run_id = "truncation-test-run" + run_dir = mock_readouts_dir / run_id + run_dir.mkdir() + + # Generate CSV with more rows than the limit + num_rows = MAX_SQL_RESULT_ROWS + 10 + csv_lines = ["# Header\nID,VALUE"] + csv_lines.extend([f"{i},{i * 10}" for i in range(num_rows)]) + cell_file = run_dir / "cell_readouts_slide001.csv" + cell_file.write_text("\n".join(csv_lines)) + + with ( + patch("aignostics.mcp._server.Client") as mock_get_client, + patch("aignostics.mcp._server._resolve_run_id") as mock_resolve, + ): + mock_resolve.return_value = run_id + mock_client = MagicMock() + mock_get_client.return_value = mock_client + + result = query_readouts_sql(run_id, "SELECT * FROM cells") + + # Should indicate truncation + assert "(truncated)" in result + assert f"{MAX_SQL_RESULT_ROWS} rows" in result + + def test_query_readouts_sql_no_truncation_when_under_limit(self, run_with_readouts: str) -> None: + """Test that query_readouts_sql does not show truncation for small results.""" + from aignostics.mcp._server import query_readouts_sql + + with ( + patch("aignostics.mcp._server.Client") as mock_get_client, + patch("aignostics.mcp._server._resolve_run_id") as mock_resolve, + ): + mock_resolve.return_value = run_with_readouts + mock_client = MagicMock() + mock_get_client.return_value = mock_client + + result = query_readouts_sql(run_with_readouts, "SELECT * FROM cells") + + # Should not indicate truncation (only 3 rows in test data) + assert "(truncated)" not in result + assert "3 rows" in result + + def test_get_readout_schema_returns_columns(self, run_with_readouts: str) -> None: + """Test that get_readout_schema returns column information.""" + from aignostics.mcp._server import get_readout_schema + + with ( + patch("aignostics.mcp._server.Client") as mock_get_client, + patch("aignostics.mcp._server._resolve_run_id") as mock_resolve, + ): + mock_resolve.return_value = run_with_readouts + mock_client = MagicMock() + mock_get_client.return_value = mock_client + + result = get_readout_schema(run_with_readouts, "cell") + + assert "## Cell Readout Schema" in result + assert "| Column | Type |" in result + assert "CELL_CLASS" in result + + def test_get_run_items_returns_markdown_table(self) -> None: + """Test that get_run_items returns a properly formatted markdown table.""" + from aignostics.mcp._server import get_run_items + from aignostics.platform import ItemOutput, ItemState + + mock_item = MagicMock() + mock_item.external_id = "slide001.tiff" + mock_item.state = ItemState.TERMINATED + mock_item.output = ItemOutput.FULL + mock_item.error_message = None + + with ( + patch("aignostics.mcp._server.Client") as mock_get_client, + patch("aignostics.mcp._server._resolve_run_id") as mock_resolve, + ): + mock_resolve.return_value = "run-789" + mock_client = MagicMock() + mock_run = MagicMock() + mock_run.results.return_value = iter([mock_item]) + mock_client.runs.return_value = mock_run + mock_get_client.return_value = mock_client + + result = get_run_items("run-789") + + assert "## Items in Run: run-789" in result + assert "| # | External ID |" in result + assert "slide001.tiff" in result + assert "TERMINATED" in result + + def test_get_run_items_no_items(self) -> None: + """Test get_run_items when no items exist.""" + from aignostics.mcp._server import get_run_items + + with ( + patch("aignostics.mcp._server.Client") as mock_get_client, + patch("aignostics.mcp._server._resolve_run_id") as mock_resolve, + ): + mock_resolve.return_value = "run-empty" + mock_client = MagicMock() + mock_run = MagicMock() + mock_run.results.return_value = iter([]) + mock_client.runs.return_value = mock_run + mock_get_client.return_value = mock_client + + result = get_run_items("run-empty") + + assert result == "No items found in this run." + + def test_download_readouts_downloads_files(self, mock_readouts_dir: Path, clean_caches: None) -> None: + """Test that download_readouts downloads readout files.""" + from aignostics.mcp._server import download_readouts + from aignostics.platform import ItemOutput + + mock_item = MagicMock() + mock_item.external_id = "slide001.tiff" + mock_item.output = ItemOutput.FULL + + mock_artifact = MagicMock() + mock_artifact.name = "cell_readout.csv" + mock_artifact.download_url = "https://example.com/cell_readout.csv" + mock_item.output_artifacts = [mock_artifact] + + csv_content = b"# Header\nCOL1,COL2\nval1,val2\n" + + mock_response = MagicMock() + mock_response.content = csv_content + mock_response.raise_for_status = MagicMock() + + with ( + patch("aignostics.mcp._server.Client") as mock_get_client, + patch("aignostics.mcp._server._resolve_run_id") as mock_resolve, + patch("aignostics.mcp._server.requests.get") as mock_requests_get, + ): + mock_requests_get.return_value = mock_response + mock_resolve.return_value = "download-run" + mock_client = MagicMock() + mock_run = MagicMock() + mock_run.results.return_value = iter([mock_item]) + mock_client.runs.return_value = mock_run + mock_get_client.return_value = mock_client + + result = download_readouts("download-run") + + assert "## Downloaded Readouts" in result + assert "Cell readouts" in result + + def test_download_readouts_no_readouts(self) -> None: + """Test download_readouts when no readouts are available.""" + from aignostics.mcp._server import download_readouts + from aignostics.platform import ItemOutput + + mock_item = MagicMock() + mock_item.external_id = "slide001.tiff" + mock_item.output = ItemOutput.NONE # Not FULL, so no artifacts downloaded + mock_item.output_artifacts = [] + + with ( + patch("aignostics.mcp._server.Client") as mock_get_client, + patch("aignostics.mcp._server._resolve_run_id") as mock_resolve, + ): + mock_resolve.return_value = "no-readouts-run" + mock_client = MagicMock() + mock_run = MagicMock() + mock_run.results.return_value = iter([mock_item]) + mock_client.runs.return_value = mock_run + mock_get_client.return_value = mock_client + + result = download_readouts("no-readouts-run") + + assert "No readouts found" in result + + +# ============================================================================= +# Tests: MCP Resources +# ============================================================================= + + +@pytest.mark.unit +class TestMCPResources: + """Tests for MCP resource functions. + + Note: MCP resources decorated with @mcp.resource() become Resource objects. + We call the decorated functions directly. + """ + + def test_cell_schema_resource_returns_schema(self, run_with_readouts: str) -> None: + """Test cell schema resource returns column information after cache is populated.""" + from aignostics.mcp._server import cell_schema_resource + + # First populate the cache by calling _get_schema + _get_schema(run_with_readouts, "cell") + + # Now the static resource should return the schema + result = cell_schema_resource() + + assert "## Cell Readout Schema" in result + assert "CELL_CLASS" in result + + def test_cell_schema_resource_not_yet_discovered(self, mock_readouts_dir: Path, clean_caches: None) -> None: + """Test cell schema resource returns helpful message when not yet discovered.""" + from aignostics.mcp._server import cell_schema_resource + + result = cell_schema_resource() + + assert "Cell schema not yet available" in result + assert "download_readouts" in result + assert "get_readout_schema" in result + + def test_slide_schema_resource_returns_schema(self, run_with_readouts: str) -> None: + """Test slide schema resource returns column information after cache is populated.""" + from aignostics.mcp._server import slide_schema_resource + + # First populate the cache by calling _get_schema + _get_schema(run_with_readouts, "slide") + + # Now the static resource should return the schema + result = slide_schema_resource() + + assert "## Slide Readout Schema" in result + assert "SLIDE_ID" in result + + def test_slide_schema_resource_not_yet_discovered(self, mock_readouts_dir: Path, clean_caches: None) -> None: + """Test slide schema resource returns helpful message when not yet discovered.""" + from aignostics.mcp._server import slide_schema_resource + + result = slide_schema_resource() + + assert "Slide schema not yet available" in result + assert "download_readouts" in result + assert "get_readout_schema" in result + + def test_schema_resource_persists_after_clearing_duckdb(self, run_with_readouts: str) -> None: + """Test that schema resource works after clearing DuckDB connection.""" + from aignostics.mcp._server import cell_schema_resource + + # Populate the schema cache + _get_schema(run_with_readouts, "cell") + + # Clear the DuckDB connection (simulates re-downloading readouts) + _clear_duckdb_connection(run_with_readouts) + + # Schema should still be available (it's global, not per-run) + result = cell_schema_resource() + assert "## Cell Readout Schema" in result + assert "CELL_CLASS" in result + + +# ============================================================================= +# Tests: Auth Retry Decorator +# ============================================================================= + + +@pytest.mark.unit +class TestAuthRetryDecorator: + """Tests for the auth retry decorator.""" + + def test_retry_on_auth_failure_retries_once(self) -> None: + """Test that auth failure triggers retry.""" + from aignx.codegen.exceptions import UnauthorizedException + + from aignostics.mcp._server import _retry_on_auth_failure + + call_count = 0 + err_msg = "Token expired" + + @_retry_on_auth_failure + def failing_then_succeeding() -> str: + nonlocal call_count + call_count += 1 + if call_count == 1: + raise UnauthorizedException(err_msg) + return "success" + + with ( + patch("aignostics.mcp._server.remove_cached_token"), + patch("aignostics.mcp._server._clear_client_cache"), + ): + result = failing_then_succeeding() + + assert result == "success" + assert call_count == 2 + + def test_retry_clears_token_on_failure(self) -> None: + """Test that token is cleared on auth failure.""" + from aignx.codegen.exceptions import UnauthorizedException + + from aignostics.mcp._server import _retry_on_auth_failure + + err_msg = "Token expired" + + @_retry_on_auth_failure + def always_fails() -> None: + raise UnauthorizedException(err_msg) + + with ( + patch("aignostics.mcp._server.remove_cached_token") as mock_remove, + patch("aignostics.mcp._server._clear_client_cache") as mock_clear, + pytest.raises(UnauthorizedException), + ): + always_fails() + + mock_remove.assert_called_once() + mock_clear.assert_called_once() + + +# ============================================================================= +# Tests: Chart Configuration Builders (Consolidated) +# ============================================================================= + + +@pytest.mark.unit +class TestChartBuilders: + """Tests for chart configuration builder functions using parameterization.""" + + @pytest.mark.parametrize("chart_type", ["bar", "pie", "line"]) + def test_build_chart_config_labels_values(self, chart_type: str) -> None: + """Test chart types that use labels and values.""" + from aignostics.mcp._charts import build_chart_config + + config = build_chart_config( + chart_type=chart_type, # type: ignore[arg-type] + labels=["A", "B", "C"], + values=[10, 20, 30], + title=f"Test {chart_type.title()} Chart", + ) + + expected_type = "bar" if chart_type == "bar" else chart_type + assert config["type"] == expected_type + assert config["_meta"]["title"] == f"Test {chart_type.title()} Chart" + + def test_build_chart_config_histogram(self) -> None: + """Test histogram chart configuration.""" + from aignostics.mcp._charts import build_chart_config + + values = [1.0, 2.0, 2.5, 3.0, 3.5, 4.0, 4.5, 5.0, 5.5, 6.0] + config = build_chart_config( + chart_type="histogram", + values=values, + bins=5, + title="Test Histogram", + ) + + assert config["type"] == "bar" # Histogram rendered as bar + assert len(config["data"]["labels"]) == 5 + assert sum(config["data"]["datasets"][0]["data"]) == 10 + assert "10 values in 5 bins" in config["_meta"]["subtitle"] + + def test_build_chart_config_scatter(self) -> None: + """Test scatter chart configuration.""" + from aignostics.mcp._charts import build_chart_config + + config = build_chart_config( + chart_type="scatter", + x_values=[1.0, 2.0, 3.0], + y_values=[4.0, 5.0, 6.0], + title="Test Scatter", + ) + + assert config["type"] == "scatter" + assert len(config["data"]["datasets"]) == 1 + assert len(config["data"]["datasets"][0]["data"]) == 3 + assert "3 points" in config["_meta"]["subtitle"] + + def test_build_chart_config_scatter_with_colors(self) -> None: + """Test scatter chart with color grouping.""" + from aignostics.mcp._charts import build_chart_config + + config = build_chart_config( + chart_type="scatter", + x_values=[1.0, 2.0, 3.0, 4.0], + y_values=[1.0, 2.0, 3.0, 4.0], + color_values=["A", "A", "B", "B"], + ) + + assert config["type"] == "scatter" + assert len(config["data"]["datasets"]) == 2 # Two groups + + @pytest.mark.parametrize( + ("chart_type", "values"), + [ + ("histogram", []), + ("scatter", []), + ("line", []), + ], + ) + def test_build_chart_config_empty_values(self, chart_type: str, values: list) -> None: + """Test chart types handle empty values gracefully.""" + from aignostics.mcp._charts import build_chart_config + + if chart_type == "scatter": + config = build_chart_config(chart_type=chart_type, x_values=values, y_values=values) # type: ignore[arg-type] + else: + config = build_chart_config(chart_type=chart_type, values=values, labels=values) # type: ignore[arg-type] + + assert "error" in config + + +@pytest.mark.unit +class TestChartFromSqlResult: + """Tests for building charts from SQL results.""" + + @pytest.mark.parametrize("chart_type", ["bar", "pie"]) + def test_build_chart_from_sql_result(self, run_with_readouts: str, chart_type: str) -> None: + """Test building charts from SQL result.""" + from aignostics.mcp._charts import build_chart_from_sql_result + from aignostics.mcp._server import _get_duckdb_connection + + con = _get_duckdb_connection(run_with_readouts) + result = con.execute("SELECT CELL_CLASS, COUNT(*) as count FROM cells GROUP BY CELL_CLASS") + + config = build_chart_from_sql_result(result=result, chart_type=chart_type, title="Cell Distribution") # type: ignore[arg-type] + + assert config["type"] == chart_type + assert config["_meta"]["title"] == "Cell Distribution" + + def test_build_chart_from_sql_result_empty(self, run_with_readouts: str) -> None: + """Test building chart from empty SQL result.""" + from aignostics.mcp._charts import build_chart_from_sql_result + from aignostics.mcp._server import _get_duckdb_connection + + con = _get_duckdb_connection(run_with_readouts) + result = con.execute("SELECT CELL_CLASS FROM cells WHERE 1=0") + + config = build_chart_from_sql_result(result=result, chart_type="bar") + + assert "error" in config + assert "no results" in config["error"].lower() + + def test_build_chart_from_sql_result_invalid_column(self, run_with_readouts: str) -> None: + """Test building chart with invalid column name.""" + from aignostics.mcp._charts import build_chart_from_sql_result + from aignostics.mcp._server import _get_duckdb_connection + + con = _get_duckdb_connection(run_with_readouts) + result = con.execute("SELECT CELL_CLASS, COUNT(*) as count FROM cells GROUP BY CELL_CLASS") + + config = build_chart_from_sql_result(result=result, chart_type="bar", x_column="nonexistent_column") + + assert "error" in config + assert "not found" in config["error"].lower() + + def test_build_chart_from_sql_result_truncation_metadata(self, mock_readouts_dir: Path, clean_caches: None) -> None: + """Test that chart builder adds truncation metadata when results exceed limit.""" + from aignostics.mcp._charts import build_chart_from_sql_result + from aignostics.mcp._constants import MAX_CHART_POINTS + from aignostics.mcp._server import _get_duckdb_connection + + # Create a run with many rows (more than MAX_CHART_POINTS) + run_id = "chart-truncation-test" + run_dir = mock_readouts_dir / run_id + run_dir.mkdir() + + # Generate CSV with more rows than the chart limit + num_rows = MAX_CHART_POINTS + 100 + csv_lines = ["# Header\nX,Y"] + csv_lines.extend([f"{i},{i * 2}" for i in range(num_rows)]) + cell_file = run_dir / "cell_readouts_slide001.csv" + cell_file.write_text("\n".join(csv_lines)) + + con = _get_duckdb_connection(run_id) + result = con.execute("SELECT X, Y FROM cells") + + config = build_chart_from_sql_result(result=result, chart_type="scatter") + + # Should have truncation metadata + assert "_meta" in config + assert config["_meta"]["truncated"] is True + assert "truncation_message" in config["_meta"] + assert str(MAX_CHART_POINTS) in config["_meta"]["truncation_message"] + assert config["_meta"]["row_count"] == MAX_CHART_POINTS + + def test_build_chart_from_sql_result_no_truncation_small_data(self, run_with_readouts: str) -> None: + """Test that chart builder does not add truncation metadata for small results.""" + from aignostics.mcp._charts import build_chart_from_sql_result + from aignostics.mcp._server import _get_duckdb_connection + + con = _get_duckdb_connection(run_with_readouts) + result = con.execute("SELECT CELL_CLASS, COUNT(*) as count FROM cells GROUP BY CELL_CLASS") + + config = build_chart_from_sql_result(result=result, chart_type="bar") + + # Should have row_count but NOT truncation + assert "_meta" in config + assert config["_meta"]["row_count"] == 3 # 3 cell types in test data + assert "truncated" not in config["_meta"] + + +@pytest.mark.unit +class TestColorGeneration: + """Tests for color generation utility.""" + + @pytest.mark.parametrize("count", [3, 20]) + def test_generate_colors(self, count: int) -> None: + """Test color generation produces valid CSS colors.""" + from aignostics.mcp._charts import _generate_colors + + colors = _generate_colors(count) + assert len(colors) == count + assert all("rgba" in c or "hsla" in c for c in colors) + + +# ============================================================================= +# Tests: Visualization Tool +# ============================================================================= + + +@pytest.mark.unit +class TestVisualizeReadoutsTool: + """Tests for the visualize_readouts MCP tool.""" + + @pytest.mark.parametrize("chart_type", ["bar", "pie", "scatter"]) + def test_visualize_readouts_chart_types(self, run_with_readouts: str, chart_type: str) -> None: + """Test visualize_readouts with different chart types.""" + import json + + from fastmcp.tools.tool import ToolResult + + from aignostics.mcp._server import visualize_readouts + + sql = "SELECT CELL_CLASS, COUNT(*) as count FROM cells GROUP BY CELL_CLASS" + if chart_type == "scatter": + sql = "SELECT CENTROID_X, CENTROID_Y, CELL_CLASS FROM cells" + + with ( + patch("aignostics.mcp._server.Client") as mock_get_client, + patch("aignostics.mcp._server._resolve_run_id") as mock_resolve, + ): + mock_resolve.return_value = run_with_readouts + mock_client = MagicMock() + mock_get_client.return_value = mock_client + + result = visualize_readouts( + run_id=run_with_readouts, + chart_type=chart_type, # type: ignore[arg-type] + sql=sql, + ) + + assert isinstance(result, ToolResult) + data = json.loads(result.content[0].text) + assert data["type"] == chart_type + + def test_visualize_readouts_sql_error(self, run_with_readouts: str) -> None: + """Test visualize_readouts handles SQL errors gracefully.""" + import json + + from aignostics.mcp._server import visualize_readouts + + with ( + patch("aignostics.mcp._server.Client") as mock_get_client, + patch("aignostics.mcp._server._resolve_run_id") as mock_resolve, + ): + mock_resolve.return_value = run_with_readouts + mock_client = MagicMock() + mock_get_client.return_value = mock_client + + result = visualize_readouts( + run_id=run_with_readouts, + chart_type="bar", + sql="SELECT nonexistent_column FROM cells", + ) + + data = json.loads(result.content[0].text) + assert "error" in data + assert "SQL Error" in data["error"] + + def test_visualize_readouts_run_not_found(self) -> None: + """Test visualize_readouts when run is not found.""" + import json + + from aignx.codegen.exceptions import NotFoundException + + from aignostics.mcp._server import visualize_readouts + + with ( + patch("aignostics.mcp._server.Client") as mock_get_client, + patch("aignostics.mcp._server._resolve_run_id") as mock_resolve, + ): + mock_resolve.side_effect = NotFoundException("Not found") + mock_client = MagicMock() + mock_get_client.return_value = mock_client + + result = visualize_readouts( + run_id="nonexistent-run", + chart_type="bar", + sql="SELECT * FROM cells", + ) + + data = json.loads(result.content[0].text) + assert "error" in data + assert "not found" in data["error"].lower() + + +# ============================================================================= +# Tests: Chart UI Resource +# ============================================================================= + + +@pytest.mark.unit +class TestChartUIResource: + """Tests for the chart UI MCP resource.""" + + def test_chart_view_returns_html(self) -> None: + """Test that chart_view resource returns valid HTML with MCP Apps SDK.""" + from aignostics.mcp._server import chart_view + + html = chart_view() + + # Check basic HTML structure + assert "" in html + assert 'id="chart"' in html + assert "canvas" in html.lower() + assert "chart.js" in html.lower() + assert "new App(" in html + # Check MCP Apps SDK integration + assert "@modelcontextprotocol/ext-apps" in html + assert "ontoolresult" in html diff --git a/tests/aignostics/utils/mcp_test.py b/tests/aignostics/utils/mcp_test.py index 28ae4771..386e4aea 100644 --- a/tests/aignostics/utils/mcp_test.py +++ b/tests/aignostics/utils/mcp_test.py @@ -87,8 +87,8 @@ def plugin2_tool() -> str: assert isinstance(server, FastMCP) assert server.name == MCP_SERVER_NAME # Verify exactly 2 tools from both plugins are mounted with namespacing - tools = asyncio.run(server.get_tools()) - tool_names = list(tools.keys()) + tools = asyncio.run(server.list_tools()) + tool_names = [tool.name for tool in tools] assert len(tool_names) == 2 # Verify namespacing: tools should be prefixed with server name plugin1_tools = [n for n in tool_names if "plugin1" in n] @@ -127,8 +127,8 @@ def unique_tool() -> str: # Verify warning was logged for duplicate assert "Duplicate MCP server name 'duplicate_name'" in caplog.text # Verify only first duplicate and unique server were mounted (2 servers, not 3) - tools = asyncio.run(server.get_tools()) - tool_names = list(tools.keys()) + tools = asyncio.run(server.list_tools()) + tool_names = [tool.name for tool in tools] assert len(tool_names) == 2 # dup1_tool should be present (first occurrence) assert any("dup1_tool" in name for name in tool_names) diff --git a/uv.lock b/uv.lock index c92e54f6..7ea118b4 100644 --- a/uv.lock +++ b/uv.lock @@ -168,7 +168,7 @@ requires-dist = [ { name = "dicomweb-client", extras = ["gcp"], specifier = ">=0.59.3,<1" }, { name = "duckdb", specifier = ">=1.4.2,<=2" }, { name = "fastapi", extras = ["all", "standard"], specifier = ">=0.123.10" }, - { name = "fastmcp", specifier = ">=2.0.0,<3" }, + { name = "fastmcp", git = "https://github.com/jlowin/fastmcp.git?rev=c8e2c621ef9e568f698e99085ed5d9e5d96678c6" }, { name = "fastparquet", marker = "python_full_version < '3.14'", specifier = ">=2025.12.0,<2026.0.0" }, { name = "filelock", specifier = ">=3.20.1" }, { name = "google-cloud-storage", specifier = ">=3.6.0,<4" }, @@ -593,15 +593,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/03/49/d10027df9fce941cb8184e78a02857af36360d33e1721df81c5ed2179a1a/async_lru-2.0.5-py3-none-any.whl", hash = "sha256:ab95404d8d2605310d345932697371a5f40def0487c03d6d0ad9138de52c9943", size = 6069, upload-time = "2025-03-16T17:25:35.422Z" }, ] -[[package]] -name = "async-timeout" -version = "5.0.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a5/ae/136395dfbfe00dfc94da3f3e136d0b13f394cba8f4841120e34226265780/async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3", size = 9274, upload-time = "2024-11-06T16:41:39.6Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fe/ba/e2081de779ca30d473f21f5b30e0e737c438205440784c7dfc81efc2b029/async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c", size = 6233, upload-time = "2024-11-06T16:41:37.9Z" }, -] - [[package]] name = "attrs" version = "25.4.0" @@ -1841,24 +1832,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/17/93/00c94d45f55c336434a15f98d906387e87ce28f9918e4444829a8fda432d/faker-38.2.0-py3-none-any.whl", hash = "sha256:35fe4a0a79dee0dc4103a6083ee9224941e7d3594811a50e3969e547b0d2ee65", size = 1980505, upload-time = "2025-11-19T16:37:30.208Z" }, ] -[[package]] -name = "fakeredis" -version = "2.33.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "redis" }, - { name = "sortedcontainers" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/5f/f9/57464119936414d60697fcbd32f38909bb5688b616ae13de6e98384433e0/fakeredis-2.33.0.tar.gz", hash = "sha256:d7bc9a69d21df108a6451bbffee23b3eba432c21a654afc7ff2d295428ec5770", size = 175187, upload-time = "2025-12-16T19:45:52.269Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6e/78/a850fed8aeef96d4a99043c90b818b2ed5419cd5b24a4049fd7cfb9f1471/fakeredis-2.33.0-py3-none-any.whl", hash = "sha256:de535f3f9ccde1c56672ab2fdd6a8efbc4f2619fc2f1acc87b8737177d71c965", size = 119605, upload-time = "2025-12-16T19:45:51.08Z" }, -] - -[package.optional-dependencies] -lua = [ - { name = "lupa" }, -] - [[package]] name = "fastapi" version = "0.124.0" @@ -2043,8 +2016,8 @@ wheels = [ [[package]] name = "fastmcp" -version = "2.14.4" -source = { registry = "https://pypi.org/simple" } +version = "3.0.0b2.dev68+c8e2c621" +source = { git = "https://github.com/jlowin/fastmcp.git?rev=c8e2c621ef9e568f698e99085ed5d9e5d96678c6#c8e2c621ef9e568f698e99085ed5d9e5d96678c6" } dependencies = [ { name = "authlib" }, { name = "cyclopts" }, @@ -2054,21 +2027,19 @@ dependencies = [ { name = "jsonschema-path" }, { name = "mcp" }, { name = "openapi-pydantic" }, + { name = "opentelemetry-api" }, { name = "packaging" }, { name = "platformdirs" }, { name = "py-key-value-aio", extra = ["disk", "keyring", "memory"] }, { name = "pydantic", extra = ["email"] }, - { name = "pydocket" }, { name = "pyperclip" }, { name = "python-dotenv" }, + { name = "pyyaml" }, { name = "rich" }, { name = "uvicorn" }, + { name = "watchfiles" }, { name = "websockets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/fd/a9/a57d5e5629ebd4ef82b495a7f8e346ce29ef80cc86b15c8c40570701b94d/fastmcp-2.14.4.tar.gz", hash = "sha256:c01f19845c2adda0a70d59525c9193be64a6383014c8d40ce63345ac664053ff", size = 8302239, upload-time = "2026-01-22T17:29:37.024Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3e/41/c4d407e2218fd60d84acb6cc5131d28ff876afecf325e3fd9d27b8318581/fastmcp-2.14.4-py3-none-any.whl", hash = "sha256:5858cff5e4c8ea8107f9bca2609d71d6256e0fce74495912f6e51625e466c49a", size = 417788, upload-time = "2026-01-22T17:29:35.159Z" }, -] [[package]] name = "fastparquet" @@ -3600,69 +3571,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/79/3c/e884d06859f9a9fc64afd21c426b9d681af0856181c1fe66571a65d35ef7/loro-1.10.3-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:ae4c765671ee7d7618962ec11cb3bb471965d9b88c075166fe383263235d58d6", size = 3553653, upload-time = "2025-12-09T10:13:47.917Z" }, ] -[[package]] -name = "lupa" -version = "2.6" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b8/1c/191c3e6ec6502e3dbe25a53e27f69a5daeac3e56de1f73c0138224171ead/lupa-2.6.tar.gz", hash = "sha256:9a770a6e89576be3447668d7ced312cd6fd41d3c13c2462c9dc2c2ab570e45d9", size = 7240282, upload-time = "2025-10-24T07:20:29.738Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ca/29/1f66907c1ebf1881735afa695e646762c674f00738ebf66d795d59fc0665/lupa-2.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6d988c0f9331b9f2a5a55186701a25444ab10a1432a1021ee58011499ecbbdd5", size = 962875, upload-time = "2025-10-24T07:17:39.107Z" }, - { url = "https://files.pythonhosted.org/packages/e6/67/4a748604be360eb9c1c215f6a0da921cd1a2b44b2c5951aae6fb83019d3a/lupa-2.6-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:ebe1bbf48259382c72a6fe363dea61a0fd6fe19eab95e2ae881e20f3654587bf", size = 1935390, upload-time = "2025-10-24T07:17:41.427Z" }, - { url = "https://files.pythonhosted.org/packages/ac/0c/8ef9ee933a350428b7bdb8335a37ef170ab0bb008bbf9ca8f4f4310116b6/lupa-2.6-cp311-cp311-macosx_11_0_x86_64.whl", hash = "sha256:a8fcee258487cf77cdd41560046843bb38c2e18989cd19671dd1e2596f798306", size = 992193, upload-time = "2025-10-24T07:17:43.231Z" }, - { url = "https://files.pythonhosted.org/packages/65/46/e6c7facebdb438db8a65ed247e56908818389c1a5abbf6a36aab14f1057d/lupa-2.6-cp311-cp311-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:561a8e3be800827884e767a694727ed8482d066e0d6edfcbf423b05e63b05535", size = 1165844, upload-time = "2025-10-24T07:17:45.437Z" }, - { url = "https://files.pythonhosted.org/packages/1c/26/9f1154c6c95f175ccbf96aa96c8f569c87f64f463b32473e839137601a8b/lupa-2.6-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:af880a62d47991cae78b8e9905c008cbfdc4a3a9723a66310c2634fc7644578c", size = 1048069, upload-time = "2025-10-24T07:17:47.181Z" }, - { url = "https://files.pythonhosted.org/packages/68/67/2cc52ab73d6af81612b2ea24c870d3fa398443af8e2875e5befe142398b1/lupa-2.6-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:80b22923aa4023c86c0097b235615f89d469a0c4eee0489699c494d3367c4c85", size = 2079079, upload-time = "2025-10-24T07:17:49.755Z" }, - { url = "https://files.pythonhosted.org/packages/2e/dc/f843f09bbf325f6e5ee61730cf6c3409fc78c010d968c7c78acba3019ca7/lupa-2.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:153d2cc6b643f7efb9cfc0c6bb55ec784d5bac1a3660cfc5b958a7b8f38f4a75", size = 1071428, upload-time = "2025-10-24T07:17:51.991Z" }, - { url = "https://files.pythonhosted.org/packages/2e/60/37533a8d85bf004697449acb97ecdacea851acad28f2ad3803662487dd2a/lupa-2.6-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:3fa8777e16f3ded50b72967dc17e23f5a08e4f1e2c9456aff2ebdb57f5b2869f", size = 1181756, upload-time = "2025-10-24T07:17:53.752Z" }, - { url = "https://files.pythonhosted.org/packages/e4/f2/cf29b20dbb4927b6a3d27c339ac5d73e74306ecc28c8e2c900b2794142ba/lupa-2.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:8dbdcbe818c02a2f56f5ab5ce2de374dab03e84b25266cfbaef237829bc09b3f", size = 2175687, upload-time = "2025-10-24T07:17:56.228Z" }, - { url = "https://files.pythonhosted.org/packages/94/7c/050e02f80c7131b63db1474bff511e63c545b5a8636a24cbef3fc4da20b6/lupa-2.6-cp311-cp311-win32.whl", hash = "sha256:defaf188fde8f7a1e5ce3a5e6d945e533b8b8d547c11e43b96c9b7fe527f56dc", size = 1412592, upload-time = "2025-10-24T07:17:59.062Z" }, - { url = "https://files.pythonhosted.org/packages/6f/9a/6f2af98aa5d771cea661f66c8eb8f53772ec1ab1dfbce24126cfcd189436/lupa-2.6-cp311-cp311-win_amd64.whl", hash = "sha256:9505ae600b5c14f3e17e70f87f88d333717f60411faca1ddc6f3e61dce85fa9e", size = 1669194, upload-time = "2025-10-24T07:18:01.647Z" }, - { url = "https://files.pythonhosted.org/packages/94/86/ce243390535c39d53ea17ccf0240815e6e457e413e40428a658ea4ee4b8d/lupa-2.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:47ce718817ef1cc0c40d87c3d5ae56a800d61af00fbc0fad1ca9be12df2f3b56", size = 951707, upload-time = "2025-10-24T07:18:03.884Z" }, - { url = "https://files.pythonhosted.org/packages/86/85/cedea5e6cbeb54396fdcc55f6b741696f3f036d23cfaf986d50d680446da/lupa-2.6-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:7aba985b15b101495aa4b07112cdc08baa0c545390d560ad5cfde2e9e34f4d58", size = 1916703, upload-time = "2025-10-24T07:18:05.6Z" }, - { url = "https://files.pythonhosted.org/packages/24/be/3d6b5f9a8588c01a4d88129284c726017b2089f3a3fd3ba8bd977292fea0/lupa-2.6-cp312-cp312-macosx_11_0_x86_64.whl", hash = "sha256:b766f62f95b2739f2248977d29b0722e589dcf4f0ccfa827ccbd29f0148bd2e5", size = 985152, upload-time = "2025-10-24T07:18:08.561Z" }, - { url = "https://files.pythonhosted.org/packages/eb/23/9f9a05beee5d5dce9deca4cb07c91c40a90541fc0a8e09db4ee670da550f/lupa-2.6-cp312-cp312-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:00a934c23331f94cb51760097ebfab14b005d55a6b30a2b480e3c53dd2fa290d", size = 1159599, upload-time = "2025-10-24T07:18:10.346Z" }, - { url = "https://files.pythonhosted.org/packages/40/4e/e7c0583083db9d7f1fd023800a9767d8e4391e8330d56c2373d890ac971b/lupa-2.6-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21de9f38bd475303e34a042b7081aabdf50bd9bafd36ce4faea2f90fd9f15c31", size = 1038686, upload-time = "2025-10-24T07:18:12.112Z" }, - { url = "https://files.pythonhosted.org/packages/1c/9f/5a4f7d959d4feba5e203ff0c31889e74d1ca3153122be4a46dca7d92bf7c/lupa-2.6-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf3bda96d3fc41237e964a69c23647d50d4e28421111360274d4799832c560e9", size = 2071956, upload-time = "2025-10-24T07:18:14.572Z" }, - { url = "https://files.pythonhosted.org/packages/92/34/2f4f13ca65d01169b1720176aedc4af17bc19ee834598c7292db232cb6dc/lupa-2.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5a76ead245da54801a81053794aa3975f213221f6542d14ec4b859ee2e7e0323", size = 1057199, upload-time = "2025-10-24T07:18:16.379Z" }, - { url = "https://files.pythonhosted.org/packages/35/2a/5f7d2eebec6993b0dcd428e0184ad71afb06a45ba13e717f6501bfed1da3/lupa-2.6-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:8dd0861741caa20886ddbda0a121d8e52fb9b5bb153d82fa9bba796962bf30e8", size = 1173693, upload-time = "2025-10-24T07:18:18.153Z" }, - { url = "https://files.pythonhosted.org/packages/e4/29/089b4d2f8e34417349af3904bb40bec40b65c8731f45e3fd8d497ca573e5/lupa-2.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:239e63948b0b23023f81d9a19a395e768ed3da6a299f84e7963b8f813f6e3f9c", size = 2164394, upload-time = "2025-10-24T07:18:20.403Z" }, - { url = "https://files.pythonhosted.org/packages/f3/1b/79c17b23c921f81468a111cad843b076a17ef4b684c4a8dff32a7969c3f0/lupa-2.6-cp312-cp312-win32.whl", hash = "sha256:325894e1099499e7a6f9c351147661a2011887603c71086d36fe0f964d52d1ce", size = 1420647, upload-time = "2025-10-24T07:18:23.368Z" }, - { url = "https://files.pythonhosted.org/packages/b8/15/5121e68aad3584e26e1425a5c9a79cd898f8a152292059e128c206ee817c/lupa-2.6-cp312-cp312-win_amd64.whl", hash = "sha256:c735a1ce8ee60edb0fe71d665f1e6b7c55c6021f1d340eb8c865952c602cd36f", size = 1688529, upload-time = "2025-10-24T07:18:25.523Z" }, - { url = "https://files.pythonhosted.org/packages/28/1d/21176b682ca5469001199d8b95fa1737e29957a3d185186e7a8b55345f2e/lupa-2.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:663a6e58a0f60e7d212017d6678639ac8df0119bc13c2145029dcba084391310", size = 947232, upload-time = "2025-10-24T07:18:27.878Z" }, - { url = "https://files.pythonhosted.org/packages/ce/4c/d327befb684660ca13cf79cd1f1d604331808f9f1b6fb6bf57832f8edf80/lupa-2.6-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:d1f5afda5c20b1f3217a80e9bc1b77037f8a6eb11612fd3ada19065303c8f380", size = 1908625, upload-time = "2025-10-24T07:18:29.944Z" }, - { url = "https://files.pythonhosted.org/packages/66/8e/ad22b0a19454dfd08662237a84c792d6d420d36b061f239e084f29d1a4f3/lupa-2.6-cp313-cp313-macosx_11_0_x86_64.whl", hash = "sha256:26f2b3c085fe76e9119e48c1013c1cccdc1f51585d456858290475aa38e7089e", size = 981057, upload-time = "2025-10-24T07:18:31.553Z" }, - { url = "https://files.pythonhosted.org/packages/5c/48/74859073ab276bd0566c719f9ca0108b0cfc1956ca0d68678d117d47d155/lupa-2.6-cp313-cp313-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:60d2f902c7b96fb8ab98493dcff315e7bb4d0b44dc9dd76eb37de575025d5685", size = 1156227, upload-time = "2025-10-24T07:18:33.981Z" }, - { url = "https://files.pythonhosted.org/packages/09/6c/0e9ded061916877253c2266074060eb71ed99fb21d73c8c114a76725bce2/lupa-2.6-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a02d25dee3a3250967c36590128d9220ae02f2eda166a24279da0b481519cbff", size = 1035752, upload-time = "2025-10-24T07:18:36.32Z" }, - { url = "https://files.pythonhosted.org/packages/dd/ef/f8c32e454ef9f3fe909f6c7d57a39f950996c37a3deb7b391fec7903dab7/lupa-2.6-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6eae1ee16b886b8914ff292dbefbf2f48abfbdee94b33a88d1d5475e02423203", size = 2069009, upload-time = "2025-10-24T07:18:38.072Z" }, - { url = "https://files.pythonhosted.org/packages/53/dc/15b80c226a5225815a890ee1c11f07968e0aba7a852df41e8ae6fe285063/lupa-2.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b0edd5073a4ee74ab36f74fe61450148e6044f3952b8d21248581f3c5d1a58be", size = 1056301, upload-time = "2025-10-24T07:18:40.165Z" }, - { url = "https://files.pythonhosted.org/packages/31/14/2086c1425c985acfb30997a67e90c39457122df41324d3c179d6ee2292c6/lupa-2.6-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:0c53ee9f22a8a17e7d4266ad48e86f43771951797042dd51d1494aaa4f5f3f0a", size = 1170673, upload-time = "2025-10-24T07:18:42.426Z" }, - { url = "https://files.pythonhosted.org/packages/10/e5/b216c054cf86576c0191bf9a9f05de6f7e8e07164897d95eea0078dca9b2/lupa-2.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:de7c0f157a9064a400d828789191a96da7f4ce889969a588b87ec80de9b14772", size = 2162227, upload-time = "2025-10-24T07:18:46.112Z" }, - { url = "https://files.pythonhosted.org/packages/59/2f/33ecb5bedf4f3bc297ceacb7f016ff951331d352f58e7e791589609ea306/lupa-2.6-cp313-cp313-win32.whl", hash = "sha256:ee9523941ae0a87b5b703417720c5d78f72d2f5bc23883a2ea80a949a3ed9e75", size = 1419558, upload-time = "2025-10-24T07:18:48.371Z" }, - { url = "https://files.pythonhosted.org/packages/f9/b4/55e885834c847ea610e111d87b9ed4768f0afdaeebc00cd46810f25029f6/lupa-2.6-cp313-cp313-win_amd64.whl", hash = "sha256:b1335a5835b0a25ebdbc75cf0bda195e54d133e4d994877ef025e218c2e59db9", size = 1683424, upload-time = "2025-10-24T07:18:50.976Z" }, - { url = "https://files.pythonhosted.org/packages/66/9d/d9427394e54d22a35d1139ef12e845fd700d4872a67a34db32516170b746/lupa-2.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:dcb6d0a3264873e1653bc188499f48c1fb4b41a779e315eba45256cfe7bc33c1", size = 953818, upload-time = "2025-10-24T07:18:53.378Z" }, - { url = "https://files.pythonhosted.org/packages/10/41/27bbe81953fb2f9ecfced5d9c99f85b37964cfaf6aa8453bb11283983721/lupa-2.6-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:a37e01f2128f8c36106726cb9d360bac087d58c54b4522b033cc5691c584db18", size = 1915850, upload-time = "2025-10-24T07:18:55.259Z" }, - { url = "https://files.pythonhosted.org/packages/a3/98/f9ff60db84a75ba8725506bbf448fb085bc77868a021998ed2a66d920568/lupa-2.6-cp314-cp314-macosx_11_0_x86_64.whl", hash = "sha256:458bd7e9ff3c150b245b0fcfbb9bd2593d1152ea7f0a7b91c1d185846da033fe", size = 982344, upload-time = "2025-10-24T07:18:57.05Z" }, - { url = "https://files.pythonhosted.org/packages/41/f7/f39e0f1c055c3b887d86b404aaf0ca197b5edfd235a8b81b45b25bac7fc3/lupa-2.6-cp314-cp314-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:052ee82cac5206a02df77119c325339acbc09f5ce66967f66a2e12a0f3211cad", size = 1156543, upload-time = "2025-10-24T07:18:59.251Z" }, - { url = "https://files.pythonhosted.org/packages/9e/9c/59e6cffa0d672d662ae17bd7ac8ecd2c89c9449dee499e3eb13ca9cd10d9/lupa-2.6-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96594eca3c87dd07938009e95e591e43d554c1dbd0385be03c100367141db5a8", size = 1047974, upload-time = "2025-10-24T07:19:01.449Z" }, - { url = "https://files.pythonhosted.org/packages/23/c6/a04e9cef7c052717fcb28fb63b3824802488f688391895b618e39be0f684/lupa-2.6-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e8faddd9d198688c8884091173a088a8e920ecc96cda2ffed576a23574c4b3f6", size = 2073458, upload-time = "2025-10-24T07:19:03.369Z" }, - { url = "https://files.pythonhosted.org/packages/e6/10/824173d10f38b51fc77785228f01411b6ca28826ce27404c7c912e0e442c/lupa-2.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:daebb3a6b58095c917e76ba727ab37b27477fb926957c825205fbda431552134", size = 1067683, upload-time = "2025-10-24T07:19:06.2Z" }, - { url = "https://files.pythonhosted.org/packages/b6/dc/9692fbcf3c924d9c4ece2d8d2f724451ac2e09af0bd2a782db1cef34e799/lupa-2.6-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:f3154e68972befe0f81564e37d8142b5d5d79931a18309226a04ec92487d4ea3", size = 1171892, upload-time = "2025-10-24T07:19:08.544Z" }, - { url = "https://files.pythonhosted.org/packages/84/ff/e318b628d4643c278c96ab3ddea07fc36b075a57383c837f5b11e537ba9d/lupa-2.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e4dadf77b9fedc0bfa53417cc28dc2278a26d4cbd95c29f8927ad4d8fe0a7ef9", size = 2166641, upload-time = "2025-10-24T07:19:10.485Z" }, - { url = "https://files.pythonhosted.org/packages/12/f7/a6f9ec2806cf2d50826980cdb4b3cffc7691dc6f95e13cc728846d5cb793/lupa-2.6-cp314-cp314-win32.whl", hash = "sha256:cb34169c6fa3bab3e8ac58ca21b8a7102f6a94b6a5d08d3636312f3f02fafd8f", size = 1456857, upload-time = "2025-10-24T07:19:37.989Z" }, - { url = "https://files.pythonhosted.org/packages/c5/de/df71896f25bdc18360fdfa3b802cd7d57d7fede41a0e9724a4625b412c85/lupa-2.6-cp314-cp314-win_amd64.whl", hash = "sha256:b74f944fe46c421e25d0f8692aef1e842192f6f7f68034201382ac440ef9ea67", size = 1731191, upload-time = "2025-10-24T07:19:40.281Z" }, - { url = "https://files.pythonhosted.org/packages/47/3c/a1f23b01c54669465f5f4c4083107d496fbe6fb45998771420e9aadcf145/lupa-2.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0e21b716408a21ab65723f8841cf7f2f37a844b7a965eeabb785e27fca4099cf", size = 999343, upload-time = "2025-10-24T07:19:12.519Z" }, - { url = "https://files.pythonhosted.org/packages/c5/6d/501994291cb640bfa2ccf7f554be4e6914afa21c4026bd01bff9ca8aac57/lupa-2.6-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:589db872a141bfff828340079bbdf3e9a31f2689f4ca0d88f97d9e8c2eae6142", size = 2000730, upload-time = "2025-10-24T07:19:14.869Z" }, - { url = "https://files.pythonhosted.org/packages/53/a5/457ffb4f3f20469956c2d4c4842a7675e884efc895b2f23d126d23e126cc/lupa-2.6-cp314-cp314t-macosx_11_0_x86_64.whl", hash = "sha256:cd852a91a4a9d4dcbb9a58100f820a75a425703ec3e3f049055f60b8533b7953", size = 1021553, upload-time = "2025-10-24T07:19:17.123Z" }, - { url = "https://files.pythonhosted.org/packages/51/6b/36bb5a5d0960f2a5c7c700e0819abb76fd9bf9c1d8a66e5106416d6e9b14/lupa-2.6-cp314-cp314t-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:0334753be028358922415ca97a64a3048e4ed155413fc4eaf87dd0a7e2752983", size = 1133275, upload-time = "2025-10-24T07:19:20.51Z" }, - { url = "https://files.pythonhosted.org/packages/19/86/202ff4429f663013f37d2229f6176ca9f83678a50257d70f61a0a97281bf/lupa-2.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:661d895cd38c87658a34780fac54a690ec036ead743e41b74c3fb81a9e65a6aa", size = 1038441, upload-time = "2025-10-24T07:19:22.509Z" }, - { url = "https://files.pythonhosted.org/packages/a7/42/d8125f8e420714e5b52e9c08d88b5329dfb02dcca731b4f21faaee6cc5b5/lupa-2.6-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6aa58454ccc13878cc177c62529a2056be734da16369e451987ff92784994ca7", size = 2058324, upload-time = "2025-10-24T07:19:24.979Z" }, - { url = "https://files.pythonhosted.org/packages/2b/2c/47bf8b84059876e877a339717ddb595a4a7b0e8740bacae78ba527562e1c/lupa-2.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1425017264e470c98022bba8cff5bd46d054a827f5df6b80274f9cc71dafd24f", size = 1060250, upload-time = "2025-10-24T07:19:27.262Z" }, - { url = "https://files.pythonhosted.org/packages/c2/06/d88add2b6406ca1bdec99d11a429222837ca6d03bea42ca75afa169a78cb/lupa-2.6-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:224af0532d216e3105f0a127410f12320f7c5f1aa0300bdf9646b8d9afb0048c", size = 1151126, upload-time = "2025-10-24T07:19:29.522Z" }, - { url = "https://files.pythonhosted.org/packages/b4/a0/89e6a024c3b4485b89ef86881c9d55e097e7cb0bdb74efb746f2fa6a9a76/lupa-2.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9abb98d5a8fd27c8285302e82199f0e56e463066f88f619d6594a450bf269d80", size = 2153693, upload-time = "2025-10-24T07:19:31.379Z" }, - { url = "https://files.pythonhosted.org/packages/b6/36/a0f007dc58fc1bbf51fb85dcc82fcb1f21b8c4261361de7dab0e3d8521ef/lupa-2.6-cp314-cp314t-win32.whl", hash = "sha256:1849efeba7a8f6fb8aa2c13790bee988fd242ae404bd459509640eeea3d1e291", size = 1590104, upload-time = "2025-10-24T07:19:33.514Z" }, - { url = "https://files.pythonhosted.org/packages/7d/5e/db903ce9cf82c48d6b91bf6d63ae4c8d0d17958939a4e04ba6b9f38b8643/lupa-2.6-cp314-cp314t-win_amd64.whl", hash = "sha256:fc1498d1a4fc028bc521c26d0fad4ca00ed63b952e32fb95949bda76a04bad52", size = 1913818, upload-time = "2025-10-24T07:19:36.039Z" }, -] - [[package]] name = "lxml" version = "6.0.2" @@ -4706,62 +4614,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cf/df/d3f1ddf4bb4cb50ed9b1139cc7b1c54c34a1e7ce8fd1b9a37c0d1551a6bd/opentelemetry_api-1.39.1-py3-none-any.whl", hash = "sha256:2edd8463432a7f8443edce90972169b195e7d6a05500cd29e6d13898187c9950", size = 66356, upload-time = "2025-12-11T13:32:17.304Z" }, ] -[[package]] -name = "opentelemetry-exporter-prometheus" -version = "0.60b1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "opentelemetry-api" }, - { name = "opentelemetry-sdk" }, - { name = "prometheus-client" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/14/39/7dafa6fff210737267bed35a8855b6ac7399b9e582b8cf1f25f842517012/opentelemetry_exporter_prometheus-0.60b1.tar.gz", hash = "sha256:a4011b46906323f71724649d301b4dc188aaa068852e814f4df38cc76eac616b", size = 14976, upload-time = "2025-12-11T13:32:42.944Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9b/0d/4be6bf5477a3eb3d917d2f17d3c0b6720cd6cb97898444a61d43cc983f5c/opentelemetry_exporter_prometheus-0.60b1-py3-none-any.whl", hash = "sha256:49f59178de4f4590e3cef0b8b95cf6e071aae70e1f060566df5546fad773b8fd", size = 13019, upload-time = "2025-12-11T13:32:23.974Z" }, -] - -[[package]] -name = "opentelemetry-instrumentation" -version = "0.60b1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "opentelemetry-api" }, - { name = "opentelemetry-semantic-conventions" }, - { name = "packaging" }, - { name = "wrapt" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/41/0f/7e6b713ac117c1f5e4e3300748af699b9902a2e5e34c9cf443dde25a01fa/opentelemetry_instrumentation-0.60b1.tar.gz", hash = "sha256:57ddc7974c6eb35865af0426d1a17132b88b2ed8586897fee187fd5b8944bd6a", size = 31706, upload-time = "2025-12-11T13:36:42.515Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/77/d2/6788e83c5c86a2690101681aeef27eeb2a6bf22df52d3f263a22cee20915/opentelemetry_instrumentation-0.60b1-py3-none-any.whl", hash = "sha256:04480db952b48fb1ed0073f822f0ee26012b7be7c3eac1a3793122737c78632d", size = 33096, upload-time = "2025-12-11T13:35:33.067Z" }, -] - -[[package]] -name = "opentelemetry-sdk" -version = "1.39.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "opentelemetry-api" }, - { name = "opentelemetry-semantic-conventions" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/eb/fb/c76080c9ba07e1e8235d24cdcc4d125ef7aa3edf23eb4e497c2e50889adc/opentelemetry_sdk-1.39.1.tar.gz", hash = "sha256:cf4d4563caf7bff906c9f7967e2be22d0d6b349b908be0d90fb21c8e9c995cc6", size = 171460, upload-time = "2025-12-11T13:32:49.369Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7c/98/e91cf858f203d86f4eccdf763dcf01cf03f1dae80c3750f7e635bfa206b6/opentelemetry_sdk-1.39.1-py3-none-any.whl", hash = "sha256:4d5482c478513ecb0a5d938dcc61394e647066e0cc2676bee9f3af3f3f45f01c", size = 132565, upload-time = "2025-12-11T13:32:35.069Z" }, -] - -[[package]] -name = "opentelemetry-semantic-conventions" -version = "0.60b1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "opentelemetry-api" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/91/df/553f93ed38bf22f4b999d9be9c185adb558982214f33eae539d3b5cd0858/opentelemetry_semantic_conventions-0.60b1.tar.gz", hash = "sha256:87c228b5a0669b748c76d76df6c364c369c28f1c465e50f661e39737e84bc953", size = 137935, upload-time = "2025-12-11T13:32:50.487Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7a/5e/5958555e09635d09b75de3c4f8b9cae7335ca545d77392ffe7331534c402/opentelemetry_semantic_conventions-0.60b1-py3-none-any.whl", hash = "sha256:9fa8c8b0c110da289809292b0591220d3a7b53c1526a23021e977d68597893fb", size = 219982, upload-time = "2025-12-11T13:32:36.955Z" }, -] - [[package]] name = "orjson" version = "3.11.5" @@ -5454,9 +5306,6 @@ keyring = [ memory = [ { name = "cachetools" }, ] -redis = [ - { name = "redis" }, -] [[package]] name = "py-key-value-shared" @@ -5716,29 +5565,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/27/a6/98651e752a49f341aa99aa3f6c8ba361728dfc064242884355419df63669/pydicom-3.0.1-py3-none-any.whl", hash = "sha256:db32f78b2641bd7972096b8289111ddab01fb221610de8d7afa835eb938adb41", size = 2376126, upload-time = "2024-09-22T02:02:41.616Z" }, ] -[[package]] -name = "pydocket" -version = "0.16.6" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cloudpickle" }, - { name = "fakeredis", extra = ["lua"] }, - { name = "opentelemetry-api" }, - { name = "opentelemetry-exporter-prometheus" }, - { name = "opentelemetry-instrumentation" }, - { name = "prometheus-client" }, - { name = "py-key-value-aio", extra = ["memory", "redis"] }, - { name = "python-json-logger" }, - { name = "redis" }, - { name = "rich" }, - { name = "typer" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/72/00/26befe5f58df7cd1aeda4a8d10bc7d1908ffd86b80fd995e57a2a7b3f7bd/pydocket-0.16.6.tar.gz", hash = "sha256:b96c96ad7692827214ed4ff25fcf941ec38371314db5dcc1ae792b3e9d3a0294", size = 299054, upload-time = "2026-01-09T22:09:15.405Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0a/3f/7483e5a6dc6326b6e0c640619b5c5bd1d6e3c20e54d58f5fb86267cef00e/pydocket-0.16.6-py3-none-any.whl", hash = "sha256:683d21e2e846aa5106274e7d59210331b242d7fb0dce5b08d3b82065663ed183", size = 67697, upload-time = "2026-01-09T22:09:13.436Z" }, -] - [[package]] name = "pygments" version = "2.19.2" @@ -6545,18 +6371,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3c/26/1062c7ec1b053db9e499b4d2d5bc231743201b74051c973dadeac80a8f43/questionary-2.1.1-py3-none-any.whl", hash = "sha256:a51af13f345f1cdea62347589fbb6df3b290306ab8930713bfae4d475a7d4a59", size = 36753, upload-time = "2025-08-28T19:00:19.56Z" }, ] -[[package]] -name = "redis" -version = "7.1.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "async-timeout", marker = "python_full_version < '3.11.3'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/43/c8/983d5c6579a411d8a99bc5823cc5712768859b5ce2c8afe1a65b37832c81/redis-7.1.0.tar.gz", hash = "sha256:b1cc3cfa5a2cb9c2ab3ba700864fb0ad75617b41f01352ce5779dabf6d5f9c3c", size = 4796669, upload-time = "2025-11-19T15:54:39.961Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/89/f0/8956f8a86b20d7bb9d6ac0187cf4cd54d8065bc9a1a09eb8011d4d326596/redis-7.1.0-py3-none-any.whl", hash = "sha256:23c52b208f92b56103e17c5d06bdc1a6c2c0b3106583985a76a18f83b265de2b", size = 354159, upload-time = "2025-11-19T15:54:38.064Z" }, -] - [[package]] name = "referencing" version = "0.36.2"