Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,268 changes: 1,197 additions & 71 deletions docs/mcp-server-plan.md

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ dependencies = [
"python-multipart>=0.0.32",
"jinja2>=3.1",
"aiosqlite>=0.22.1",
"fastmcp>=3",
]

[project.optional-dependencies]
Expand Down
2 changes: 2 additions & 0 deletions src/snore/cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ def cli(verbose: bool) -> None:

def _register_commands() -> None:
from snore.cli.commands.import_data import import_data
from snore.cli.commands.mcp import mcp
from snore.cli.commands.serve import serve
from snore.cli.commands.setup import setup
from snore.cli.commands.stats import stats
Expand All @@ -76,6 +77,7 @@ def _register_commands() -> None:
cli.add_command(stats)
cli.add_command(validate)
cli.add_command(serve)
cli.add_command(mcp)

cli.add_command(db)
cli.add_command(session)
Expand Down
57 changes: 57 additions & 0 deletions src/snore/cli/commands/mcp.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
"""mcp command — launch the SNORE MCP server over stdio."""

from __future__ import annotations

import logging

import click

logger = logging.getLogger(__name__)


@click.command()
@click.option("--db", default=None, help="Path to SQLite database file")
@click.option(
"--profile",
default="neutral",
show_default=True,
help="Clinical profile: neutral, uars, osa, csa",
)
@click.option(
"--transport",
default="stdio",
show_default=True,
help="Transport mode (stdio now; http in a future release)",
)
def mcp(db: str | None, profile: str, transport: str) -> None:
"""Launch the SNORE MCP server.

Starts the FastMCP server using the stdio transport (default), suitable
for Claude Desktop / Claude Code integration.

Database resolution uses the same precedence chain as 'snore serve':
--db > SNORE_DATABASE_URL > SNORE_DB_PATH > default SQLite path

Clinical profiles shape the INSTRUCTIONS resource and priority hints only;
they do not change the data returned by any tool (G1). Available profiles:
neutral (default), uars, osa, csa.
"""
from snore.mcp.profiles import VALID_PROFILES

if profile not in VALID_PROFILES:
raise click.BadParameter(
f"Unknown profile {profile!r}. Choose from: {sorted(VALID_PROFILES)}",
param_hint="--profile",
)

if transport != "stdio":
raise click.BadParameter(
f"Transport {transport!r} is not yet supported. Only 'stdio' is available.",
param_hint="--transport",
)

from snore.mcp.server import make_server

logger.debug("snore mcp: profile=%s db=%r", profile, db)
server = make_server(db_flag=db, profile_name=profile)
server.run(transport=transport) # type: ignore[arg-type] # validated above
1 change: 1 addition & 0 deletions src/snore/mcp/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""SNORE MCP server package."""
124 changes: 124 additions & 0 deletions src/snore/mcp/docs/tools.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
# SNORE MCP Tools

SNORE MCP server provides LLM-accessible tools for PAP therapy data analysis.
All tools are **stateless service-layer calls** — they never store state between calls.

## General Information

### Date Format
All `date`, `start`, `end` parameters accept **YYYY-MM-DD** format only.
Example: `"2025-08-01"`.

### Null fields and reasons
When a data field is absent (device does not record it, analysis has not been run, etc.)
the field is `null` and a companion `*_reason` field explains why.
Example: `"rera_index": null, "rera_index_reason": "analysis_not_run"`.

### Device capabilities block
Most tools return a `device_capabilities` block declaring what the device/dataset
actually provides for the queried range. Do not assume a channel is present — always
check this block before interpreting a null value.

### Clinical profiles
The server is configured with a clinical profile (`neutral` by default). Profiles
shape the instructions and priority hints only — tools always return the same data
regardless of profile. To change the active profile, restart the server with
`snore mcp --profile <name>`. Available profiles: `neutral`, `uars`, `osa`, `csa`.

## Recommended Workflow

1. **Orient** — call `get_data_overview` to discover devices, date ranges, and channels.
2. **Summarize** — call `get_nightly_summary` over a range to identify nights of interest.
3. **Settings** — call `get_settings_timeline` to understand settings epochs.
4. **Events** — call `get_events` on a specific date for event-level detail.
5. (Phase 2+) `get_breath_table`, `find_windows`, `compare_epochs` for flow morphology tuning.
6. (Phase 3+) `render_window`, `get_waveform` for visual inspection and raw escape hatch.

## Tools

---

### get_data_overview

Cold-start orientation tool. Call this first to discover what is imported.

**Parameters:** none

**Returns:**
- `devices` — list of devices with id, manufacturer, model, date range, session count, therapy modes
- `date_range_start` / `date_range_end` — full imported date range (all devices)
- `total_sessions` — total enabled session count
- `available_waveform_channels` — list of waveform channel names present in any session
- `available_event_types` — list of event type codes present (e.g. `["CA", "H", "OA", "RERA"]`)
- `analysis_run` — whether any analysis results exist
- `analysis_session_count` — number of sessions with analysis results

---

### get_settings_timeline

Returns therapy settings epochs — contiguous periods with identical settings.

**Parameters:**
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| start | str (YYYY-MM-DD) | Yes | Start of date range |
| end | str (YYYY-MM-DD) | Yes | End of date range |
| device_id | int | No | Filter to a specific device |

**Returns:**
- `epochs` — list of `SettingsEpoch` objects
- `start_date`, `end_date`, `nights` — epoch span
- `settings` — dict of setting keys (mode, epr_level, epr_mode, pressure_min, pressure_max, pressure_fixed, ipap, epap, ps); absent keys are `null`
- `changed_keys` — which keys changed vs. previous epoch
- `device_id`

---

### get_nightly_summary

Per-night therapy summary for a date range. Paginated (~30 nights/call).

**Parameters:**
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| start | str (YYYY-MM-DD) | Yes | Start of date range |
| end | str (YYYY-MM-DD) | Yes | End of date range |
| device_id | int | No | Filter to a specific device |
| page | int | No | Page number (1-based, default 1) |
| page_size | int | No | Results per page (default 30, max 90) |
| compliance_threshold_hours | float | No | Compliance threshold in hours (default 4.0) |

**Returns:** `NightlySummaryResponse`
- `nights` — list of `NightlyRow` with per-night metrics
- `date`, `usage_hours`, `session_count`
- `ahi`, `oai`, `cai`, `hi` (events/hr) — null if not computed
- `rera_index` (events/hr), `rdi` — null + `rera_index_reason: "analysis_not_run"` if analysis absent
- Pressure: `pressure_median_cmh2o`, `pressure_95th_cmh2o`, `epap_median_cmh2o`
- Leak: `leak_median_lpm`, `leak_95th_lpm`
- Resp: `rr_mean_bpm`, `tv_mean_ml`, `mv_mean_lpm`
- SpO₂: `spo2_mean_pct`
- `compliance` — present in range mode: `threshold_hours`, `days_compliant`, `days_total`, `compliance_pct`

---

### get_events

Respiratory events for a single session date with per-event context.

**Parameters:**
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| date | str (YYYY-MM-DD) | Yes | Session date |
| types | list[str] | No | Event type filter (e.g. `["CA", "OA"]`) |
| min_duration | float | No | Minimum event duration in seconds |
| include_context | bool | No | Attach per-event context block (default true) |

**Returns:** `EventsResponse`
- `events` — list of `EventRow`
- `id`, `event_type`, `start_time_wall_clock`, `timezone_status`, `offset_seconds`, `duration_seconds`
- `spo2_drop_pct`, `peak_flow_limitation`
- `context` — `minutes_since_session_start` (pressure/leak/MV context in Phase 4)

**Common event_type values:** `OA` (obstructive apnea), `CA` (central apnea),
`H` (hypopnea), `RERA`, `FL` (flow limitation), `VS` (vibratory snore).
19 changes: 19 additions & 0 deletions src/snore/mcp/errors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
"""MCP-specific exception types for SNORE."""

from __future__ import annotations


class AnalysisNotRunError(Exception):
"""Raised when a tool requires analysis results that have not been computed."""


class CapabilityUnavailableError(Exception):
"""Raised when the device/dataset does not provide a requested capability."""


class ResponseSizeLimitError(Exception):
"""Raised when the tool response would exceed the size limit."""


class ValidationError(Exception):
"""Raised when tool input validation fails."""
106 changes: 106 additions & 0 deletions src/snore/mcp/profiles.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
"""Clinical profile presets for SNORE MCP.

Profiles shape the INSTRUCTIONS resource and suggested-priority hints only (G1).
No tool returns different *data* per profile — thresholds and severity ladders
live here in the instructions text, not in tool response logic.

Profiles available:
neutral (default) — no clinical framing; reports all indices equally.
uars — de-emphasizes AHI; leads with flow morphology and RERA/RDI.
osa — AHI-forward; emphasises obstructive event burden and compliance.
csa — leads with MV, periodic breathing, and central event characterization.
"""

from __future__ import annotations

from dataclasses import dataclass

VALID_PROFILES = frozenset({"neutral", "uars", "osa", "csa"})


@dataclass(frozen=True)
class ClinicalProfile:
name: str
display_name: str
priority_hint: str
clinical_context: str


_PROFILES: dict[str, ClinicalProfile] = {
"neutral": ClinicalProfile(
name="neutral",
display_name="Neutral",
priority_hint="Report all indices (AHI, RDI, flow-limitation, leak, pressure, MV) equally.",
clinical_context=(
"No clinical framing is active. Interpret indices in the context of the "
"dataset; do not apply population-level severity ladders without "
"user-supplied thresholds."
),
),
"uars": ClinicalProfile(
name="uars",
display_name="UARS (Upper Airway Resistance Syndrome)",
priority_hint=(
"De-emphasize AHI. Lead with flow morphology (flattening index, FL runs, "
"RERA count/RDI) and inspiratory effort markers. Treat RDI > threshold as "
"the primary burden index; treat AHI < 5 as consistent with UARS phenotype, "
"not as 'normal'. Pressure tuning goal: eliminate flow-limited breaths while "
"minimising leak."
),
clinical_context=(
"UARS phenotype: RDI > 30, AHI < 5, inspiratory flow morphology is the "
"primary signal. Flattening index and FL-run-ending-in-recovery-breath "
"(RERA proxy) outrank AHI as tuning targets. "
"Bilevel therapy (VAuto/ASV) context: IPAP drives upper-airway dilation; "
"EPAP provides baseline support; PS = IPAP − EPAP. "
"Thresholds used in this dataset are user-configured — do not apply "
"generic AHI severity labels."
),
),
"osa": ClinicalProfile(
name="osa",
display_name="OSA (Obstructive Sleep Apnea)",
priority_hint=(
"AHI-forward. Report OAI, CAI, HI, AHI as the primary burden. "
"Compliance (≥4 h/night) is a key secondary metric. "
"Pressure titration goal: suppress obstructive events and reduce AHI."
),
clinical_context=(
"OSA therapy context: primary goal is AHI suppression via adequate "
"pressure. Compliance tracking matters for insurance and efficacy. "
"Do not infer severity from AHI alone — report all components "
"(OAI, CAI, HI) and let the user interpret."
),
),
"csa": ClinicalProfile(
name="csa",
display_name="CSA / Periodic Breathing",
priority_hint=(
"Lead with MV, periodic-breathing percentage, and central event burden "
"(CAI). Report MV rolling variance and respiratory rate stability as "
"primary signals. Suppress back-up rate discussion unless the device "
"reports it."
),
clinical_context=(
"CSA / complex sleep apnea context: central events and periodic breathing "
"dominate. MV stability and respiratory rate regularity are the primary "
"tuning signals. Flow morphology is secondary. "
"Do not conflate CAI with OAI — report them separately."
),
),
}


def get_profile(name: str) -> ClinicalProfile:
"""Return the named profile or raise ValueError for unknown names."""
if name not in _PROFILES:
raise ValueError(
f"Unknown clinical profile {name!r}. "
f"Valid profiles: {sorted(VALID_PROFILES)}"
)
return _PROFILES[name]


def list_profiles() -> list[ClinicalProfile]:
"""Return all profiles in a stable order."""
return [_PROFILES[k] for k in ("neutral", "uars", "osa", "csa")]
Loading