From 57c2448db548affc5368601a243015bc33d769b0 Mon Sep 17 00:00:00 2001 From: Seyed Yahya Shirazi Date: Wed, 9 Sep 2026 10:11:42 -0700 Subject: [PATCH] feat: load NEMAR's dataset tools from its MCP server The NEMAR assistant's two dataset tools were dead. Both called nemar.org/api/dataexplorer/datapipeline/..., which returns 404: the legacy dataexplorer site was retired and its URLs now redirect to nemar.org/dataset/. So this replaces them rather than supplementing them. Adds an MCP client runtime. Two design points worth knowing before changing it: Discovery runs in a worker thread with its own loop, and that is not a workaround. Tools are assembled in CommunityAssistant.__init__, which is synchronous and which the FastAPI app calls from inside a running event loop, where asyncio.run raises. A dedicated thread is correct whether or not the caller has a loop. Invocation needs none of this: it is a plain coroutine. Mutation-checked, and with a bare asyncio.run the running-loop test fails while the plain synchronous one still passes. A session per call is the right shape for this server, not laziness. The NEMAR server is stateless by design: no session id, GET and DELETE on the endpoint are 405, and there is nothing to keep alive. Connect-call-close costs one round trip and removes every piece of lifecycle management a persistent client would need. This wraps the SDK directly rather than using langchain-mcp-adapters, which pins mcp<2.0.0 and so cannot negotiate the 2026-07-28 revision the server implements. The dependency goes in the server extra, next to langchain. Failure degrades and never breaks: an unreachable server yields an empty tool list and a log line, matching _load_plugin_tools' contract. A tool error comes back as TEXT rather than raised, because the server's refusals name the cap they hit or a public URL to read instead, and handing that sentence to the model lets it correct itself. The system prompt is rewritten around the six prefixed tools as a cost ladder, with correct nm/on dataset ids and nemar.org/dataset links, plus what to relay about the streaming copy: lossy is always true, effective_rate_hz below source_rate_hz means a downsampled view, zarr_verify_status null means not yet checked rather than wrong, and a non-empty filled_ranges means part of a window is not real signal. Tests are real, no mocks: a genuine MCPServer over Streamable HTTP on a real socket, driven by the real client through the real LangChain wrappers. Plus a wiring test that reads the shipped config.yaml and asserts the prompt names the tools the loader actually produces, and a network-marked tier that runs against https://mcp.nemar.org and passes. --- pyproject.toml | 5 + src/assistants/community.py | 36 ++ src/assistants/nemar/__init__.py | 23 +- src/assistants/nemar/config.yaml | 124 +++--- src/assistants/nemar/tools.py | 381 ------------------ src/tools/mcp_client.py | 162 ++++++++ .../test_assistants/test_nemar_mcp_wiring.py | 98 +++++ tests/test_tools/test_mcp_client.py | 286 +++++++++++++ tests/test_tools/test_nemar_tools.py | 290 ------------- uv.lock | 148 ++++++- 10 files changed, 813 insertions(+), 740 deletions(-) delete mode 100644 src/assistants/nemar/tools.py create mode 100644 src/tools/mcp_client.py create mode 100644 tests/test_assistants/test_nemar_mcp_wiring.py create mode 100644 tests/test_tools/test_mcp_client.py delete mode 100644 tests/test_tools/test_nemar_tools.py diff --git a/pyproject.toml b/pyproject.toml index 410b4af7..3f39b26f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -66,6 +66,11 @@ server = [ # Scheduling "apscheduler>=3.10.0,<4.0.0", "opencite>=0.5.3", + # Model Context Protocol client, for tools served by an MCP host + # (src/tools/mcp_client.py). 2.x specifically: the 2026-07-28 protocol + # revision does not exist in 1.x, and langchain-mcp-adapters -- the obvious + # alternative -- pins mcp<2.0.0, which is why this wraps the SDK directly. + "mcp>=2.2.0", ] observability = [ diff --git a/src/assistants/community.py b/src/assistants/community.py index 620d923c..82d1d048 100644 --- a/src/assistants/community.py +++ b/src/assistants/community.py @@ -10,6 +10,7 @@ - Recent GitHub activity listing (if repos configured) - Paper search (if citations configured) - Python plugin tools (if extensions configured) +- MCP server tools (if extensions configure an MCP server) """ import importlib @@ -150,6 +151,7 @@ class CommunityAssistant(ToolAgent): - Recent GitHub activity listing (if repos configured) - Paper search (if citations configured) - Python plugin tools (if extensions configured) + - MCP server tools (if extensions configure an MCP server) Args: model: The language model to use. @@ -204,6 +206,13 @@ def __init__( plugin_tools = self._load_plugin_tools(config) tools.extend(plugin_tools) + # Load tools served by configured MCP servers. Same contract as the + # plugin loader above: log and continue on failure, never raise out of + # this constructor. An assistant that cannot start because someone + # else's host is down is worse than one missing a few tools. + mcp_tools = self._load_mcp_tools(config) + tools.extend(mcp_tools) + # Generate system prompt system_prompt = self._build_system_prompt(config, additional_instructions) @@ -301,6 +310,33 @@ def _load_plugin_tools(self, config: CommunityConfig) -> list[BaseTool]: return all_tools + def _load_mcp_tools(self, config: CommunityConfig) -> list[BaseTool]: + """Load tools from configured Model Context Protocol (MCP) servers. + + Deliberately shaped exactly like `_load_plugin_tools`: a failure is + logged and skipped, and this never raises. `discover_mcp_tools` already + swallows per-server failures, so the try here covers the import itself -- + `mcp` lives in the `server` extra, and a CLI-only install must not break + on it. + """ + all_tools: list[BaseTool] = [] + + if not config.extensions or not config.extensions.mcp_servers: + return all_tools + + try: + from src.tools.mcp_client import discover_mcp_tools + except ImportError as e: + logger.error("MCP support unavailable (install the server extra): %s", e) + return all_tools + + for server in config.extensions.mcp_servers: + server_tools = discover_mcp_tools(server) + logger.info("Loaded %d tools from MCP server %s", len(server_tools), server.name) + all_tools.extend(server_tools) + + return all_tools + def _format_preloaded_section(self) -> str: """Format preloaded documents for the system prompt.""" if not self._preloaded_content: diff --git a/src/assistants/nemar/__init__.py b/src/assistants/nemar/__init__.py index 502e63af..936b62ec 100644 --- a/src/assistants/nemar/__init__.py +++ b/src/assistants/nemar/__init__.py @@ -3,17 +3,18 @@ Self-contained assistant module for discovering and exploring BIDS-formatted EEG, MEG, and iEEG datasets hosted on NEMAR (nemar.org). -This module provides specialized Python tools for NEMAR that cannot be -auto-generated from YAML: -- search_nemar_datasets: Search and filter datasets by text, modality, task, etc. -- get_nemar_dataset_details: Get full metadata for a specific dataset +This module carries no Python tools. Its dataset tools come from NEMAR's own MCP +server (`https://mcp.nemar.org/mcp`), configured under `extensions.mcp_servers` +in `config.yaml` and loaded by `src/tools/mcp_client.py`. + +That replaced two hand-written tools, `search_nemar_datasets` and +`get_nemar_dataset_details`, which called +`nemar.org/api/dataexplorer/datapipeline/...`. That endpoint returns 404: the +legacy dataexplorer site is gone and its URLs now redirect to +`nemar.org/dataset/`. So the tools had been non-functional, and the MCP +server replaces them rather than supplementing them -- `search_datasets` and +`describe_dataset` map onto the two of them almost exactly, with four more tools +for what is inside a dataset. All other configuration (system prompt, CORS, budget) is in config.yaml. """ - -from .tools import get_nemar_dataset_details, search_nemar_datasets - -__all__ = [ - "search_nemar_datasets", - "get_nemar_dataset_details", -] diff --git a/src/assistants/nemar/config.yaml b/src/assistants/nemar/config.yaml index 575316fa..c3333a44 100644 --- a/src/assistants/nemar/config.yaml +++ b/src/assistants/nemar/config.yaml @@ -82,51 +82,76 @@ system_prompt: | - When users describe research interests, search for relevant datasets - Use the search tool to browse datasets by characteristics (modality, task, HED annotations, etc.) - Use the details tool to get comprehensive information about specific datasets - - ## Using the search_nemar_datasets Tool - - This is your primary discovery tool. Use it to help users find datasets matching their needs. - - **Search strategies:** - - Text search: Search across dataset names, tasks, README content, and authors - - Modality filter: Find datasets with specific recording types (EEG, MEG, iEEG, MRI) - - Task filter: Find datasets with specific experimental paradigms - - HED filter: Find datasets with HED annotations for structured event description - - Participant range: Find datasets with sufficient subject counts - - Combine multiple filters to narrow results - - **Important guidelines:** - - Always search when users ask "find datasets", "show me datasets", "are there datasets with..." - - Search returns compact summaries (ID, name, modality, tasks, participant count, size) - - Follow up with get_nemar_dataset_details for datasets the user is interested in - - Present search results as a numbered or bulleted list with key characteristics - - ## Using the get_nemar_dataset_details Tool - - Use this to retrieve comprehensive information about a specific dataset. - - **When to use:** - - User asks "tell me more about ds00XXXX" - - User wants citation information, full README, licensing details - - After search results, when user shows interest in a specific dataset - - **Information to highlight:** - - OpenNeuro link: https://openneuro.org/datasets/{dataset_id} - - NEMAR link: https://nemar.org/dataexplorer/detail?dataset_id={dataset_id} - - Citation: Use the DatasetDOI field - - Licensing: Mention the License field (typically CC0) - - Data characteristics: Participants, sessions, modalities, tasks - - HED annotations: If present, highlight this for users interested in standardized event descriptions + - Prefer the cheapest tool that answers the question; the ladder below is ordered + + ## The NEMAR tools, cheapest first + + Your dataset tools come from NEMAR's MCP server and are named with a `nemar_` + prefix. They form a deliberate cost ladder: each step down reads more, so walk it + rather than jumping to the bottom. `nemar_describe_dataset` returns a `cost_hint` + naming the next cheapest tool. + + 1. **`nemar_search_datasets`** - which datasets match. Filters: free text, modality, + task, HED annotations, participant counts, and `has_zarr` for datasets with a + streaming copy. Start here for any "find me datasets" question. + 2. **`nemar_describe_dataset`** - what one dataset is, plus a ready-made citation + string and its DOI and license. Use it as the follow-up when a user picks one. + 3. **`nemar_list_recordings`** - which recordings and channel groups a dataset has, + with channel counts, durations, and sampling rates. + 4. **`nemar_get_events`** - the event table for one recording: onsets, trial types, + HED strings, and exact sample indices. + 5. **`nemar_render_overview`** - a PNG overview of a recording, for "what does this + look like". + 6. **`nemar_read_window`** - how to read a specific time window. By default it + returns a *recipe*: the array URL, the sample range, and the dequantization rule, + which is what a user should be given so they can read the data themselves. It can + decode a tiny window inline if you pass `taste: true` with an explicit channel + list, but that is for illustrating a signal, never for analysis. + + **Dataset identifiers** are `nm` or `on` followed by six digits, for example + `nm000329`. They are not OpenNeuro `ds` accessions. + + ## Presenting datasets + + - Link to `https://nemar.org/dataset/{dataset_id}`. + - Cite using the `citation` field from `nemar_describe_dataset` verbatim; it is + already formatted and carries the version and DOI. Do not compose your own. + - Mention the license, and say plainly when it restricts reuse. + - Search results are compact summaries; get details for the one or two the user + actually cares about rather than describing everything. + + ## Being honest about the streaming copy + + When you report anything read through `nemar_list_recordings`, `nemar_get_events`, + `nemar_render_overview` or `nemar_read_window`, those answers carry an `envelope` + with provenance. Three fields you must not paper over: + + - **`lossy` is always true.** The streaming copy is quantized and rate-capped + relative to the original recording. If `effective_rate_hz` is lower than + `source_rate_hz`, say so: the user is looking at a downsampled view. Anyone who + needs the original samples should download the BIDS files. + - **`zarr_verify_status` may be `null`**, which means the fidelity sweep has not + reached that conversion yet. That is "not checked", not "wrong" - do not describe + it as a problem, and do not imply it has been verified either. + - **`filled_ranges`** on a decoded window lists spans that had no stored data and + were filled in. If it is non-empty, say which part of the window is not real + signal. + + If a tool declines a request, read what it says. These refusals are specific and + usually name a workaround - a smaller window, or a public URL to read directly - + and relaying that is more useful than reporting a failure. ## Dataset Discovery Workflow **Typical interaction pattern:** - 1. User describes research interest: "I need EEG datasets for attention tasks" - 2. CALL search_nemar_datasets(query="attention", modality_filter="EEG") - 3. Present relevant datasets as a list - 4. User asks about specific dataset: "Tell me more about #3" - 5. CALL get_nemar_dataset_details(dataset_id="ds00XXXX") - 6. Present comprehensive information with OpenNeuro link and citation + 1. User describes a research interest: "I need EEG datasets for attention tasks" + 2. CALL `nemar_search_datasets(query="attention", modality_filter="EEG")` + 3. Present the relevant datasets as a list + 4. User asks about a specific one: "Tell me more about #3" + 5. CALL `nemar_describe_dataset(dataset_id="nm000329")` + 6. Present the details with the `nemar.org/dataset/{id}` link and the citation + 7. If they want to know what is inside: `nemar_list_recordings`, then + `nemar_get_events` or `nemar_render_overview` for one recording ## BIDS and HED Context @@ -162,8 +187,8 @@ system_prompt: | # Documentation sources (NEMAR is a data portal, so minimal docs) documentation: - title: NEMAR Data Explorer - url: https://nemar.org/dataexplorer - source_url: https://nemar.org/dataexplorer + url: https://nemar.org/dataset + source_url: https://nemar.org/dataset preload: false category: reference description: NEMAR dataset browser and exploration interface. @@ -182,10 +207,11 @@ documentation: category: reference description: Brain Imaging Data Structure specification that all NEMAR datasets follow. -# Custom tools for NEMAR API interaction +# Dataset tools come from NEMAR's MCP server (https://mcp.nemar.org/mcp), which is +# anonymous and needs no credentials. It replaced two python_plugins tools that +# called nemar.org/api/dataexplorer/datapipeline/... -- that endpoint returns 404, +# because the legacy dataexplorer site is gone, so those tools had been dead. extensions: - python_plugins: - - module: src.assistants.nemar.tools - tools: - - search_nemar_datasets - - get_nemar_dataset_details + mcp_servers: + - name: nemar + url: https://mcp.nemar.org/mcp diff --git a/src/assistants/nemar/tools.py b/src/assistants/nemar/tools.py deleted file mode 100644 index de041305..00000000 --- a/src/assistants/nemar/tools.py +++ /dev/null @@ -1,381 +0,0 @@ -"""NEMAR-specific tools for dataset discovery and exploration. - -These tools query the NEMAR public API to help researchers find and -explore BIDS-formatted EEG/MEG/iEEG datasets from OpenNeuro. - -- search_nemar_datasets: Search/filter datasets by text, modality, task, etc. -- get_nemar_dataset_details: Get full metadata for a specific dataset by ID - -The NEMAR API has no server-side search, so search_nemar_datasets fetches -all ~485 datasets and filters client-side. This is fast enough given the -small dataset count (<2s for full fetch). -""" - -import logging -import re -import time -from typing import Any - -import httpx -from langchain_core.tools import tool - -logger = logging.getLogger(__name__) - -NEMAR_API_BASE = "https://nemar.org/api/dataexplorer/datapipeline" -TABLE_NAME = "dataexplorer_dataset" -NEMAR_SEP = "===NEMAR-SEP===" - -# Simple TTL cache for dataset list (avoid hitting API on every search) -_datasets_cache: list[dict[str, Any]] = [] -_cache_timestamp: float = 0.0 -_CACHE_TTL_SECONDS: float = 300.0 # 5 minutes - - -def _fetch_all_datasets() -> list[dict[str, Any]]: - """Fetch all datasets from NEMAR API, with a 5-minute TTL cache. - - Returns: - List of dataset dicts in API response order. - - Raises: - httpx.HTTPError: If the API request fails. - """ - global _datasets_cache, _cache_timestamp # noqa: PLW0603 - - now = time.monotonic() - if _datasets_cache and (now - _cache_timestamp) < _CACHE_TTL_SECONDS: - return _datasets_cache - - url = f"{NEMAR_API_BASE}/records" - payload = {"table_name": TABLE_NAME, "start": 0, "limit": 1000} - - # NEMAR API uses GET with JSON body (unusual but required) - response = httpx.request("GET", url, json=payload, timeout=30.0) - response.raise_for_status() - data = response.json() - - entries = data.get("entries", {}) - if not entries: - logger.warning("NEMAR API returned empty entries") - return [] - - # entries is a dict with string indices: {"0": {...}, "1": {...}, ...} - numeric_keys = [k for k in entries if k.isdigit()] - datasets = [entries[k] for k in sorted(numeric_keys, key=int)] - - _datasets_cache = datasets - _cache_timestamp = now - return datasets - - -def _parse_sep_field(value: str) -> list[str]: - """Split a NEMAR multi-value field using the ===NEMAR-SEP=== delimiter.""" - if not value: - return [] - parts = value.split(NEMAR_SEP) - return [p.strip() for p in parts if p.strip()] - - -def _matches( - dataset: dict[str, Any], - query: str | None, - modality_filter: str | None, - task_filter: str | None, - has_hed: bool | None, - min_participants: int | None, -) -> bool: - """Check if a dataset matches all provided filters.""" - if query: - q = query.lower() - searchable = " ".join( - [ - str(dataset.get("name", "")), - str(dataset.get("tasks", "")), - str(dataset.get("readme", "")), - str(dataset.get("Authors", "")), - ] - ).lower() - if q not in searchable: - return False - - if modality_filter: - modalities = str(dataset.get("modalities", "")).lower() - if modality_filter.lower() not in modalities: - return False - - if task_filter: - tasks = str(dataset.get("tasks", "")).lower() - if task_filter.lower() not in tasks: - return False - - if has_hed is True and dataset.get("hedAnnotation") != 1: - return False - - if min_participants is not None: - participants = dataset.get("participants", 0) or 0 - if participants < min_participants: - return False - - return True - - -def _format_summary(dataset: dict[str, Any]) -> str: - """Format a compact summary for search results.""" - ds_id = dataset.get("id", "unknown") - name = dataset.get("name", ds_id) - modalities = dataset.get("modalities", "N/A") or "N/A" - tasks = dataset.get("tasks", "N/A") or "N/A" - participants = dataset.get("participants", 0) or 0 - size = dataset.get("byte_size_format", "unknown") or "unknown" - - # Truncate long names - if len(name) > 80: - name = name[:77] + "..." - - return ( - f"- **{ds_id}** - {name}\n" - f" Modalities: {modalities} | Tasks: {tasks} | " - f"Participants: {participants} | Size: {size}" - ) - - -@tool -def search_nemar_datasets( - query: str | None = None, - modality_filter: str | None = None, - task_filter: str | None = None, - has_hed: bool | None = None, - min_participants: int | None = None, - limit: int = 20, -) -> str: - """Search NEMAR datasets with flexible text search and filtering. - - Fetches all datasets from NEMAR and filters client-side. Returns compact - summaries suitable for browsing. Use get_nemar_dataset_details for full info. - - Args: - query: Text search across dataset names, tasks, README, and authors - (case-insensitive substring match). Example: "attention", "face", "motor". - modality_filter: Filter by recording modality. Use one of: "EEG", "MEG", - "iEEG", "MRI" (partial match, case-insensitive). - task_filter: Filter by experimental task name (partial match, - case-insensitive). Example: "rest", "gonogo", "memory". - has_hed: If True, only return datasets with HED annotations. None has no effect. - min_participants: Minimum number of participants required. - limit: Maximum results to return (default: 20, max: 50). - - Returns: - Formatted markdown string with matching dataset summaries. - """ - limit = min(limit, 50) - - try: - datasets = _fetch_all_datasets() - except httpx.HTTPError as e: - logger.warning("NEMAR API error: %s", e) - return f"Failed to fetch datasets from NEMAR: {e}" - except (ValueError, KeyError) as e: - logger.warning("Failed to parse NEMAR API response: %s", e) - return "Failed to parse NEMAR API response. Please try again later." - except Exception: - logger.exception("Unexpected error fetching NEMAR datasets") - return "Failed to fetch datasets from NEMAR. Please try again later." - - # Apply filters - matched = [ - ds - for ds in datasets - if _matches(ds, query, modality_filter, task_filter, has_hed, min_participants) - ] - - total_matched = len(matched) - if total_matched == 0: - active_filters = { - "query": f'"{query}"' if query else None, - "modality": modality_filter, - "task": task_filter, - "has_hed": "True" if has_hed else None, - "min_participants": str(min_participants) if min_participants else None, - } - filters_desc = [f"{k}={v}" for k, v in active_filters.items() if v] - return f"No datasets found matching: {', '.join(filters_desc)}. Total datasets in NEMAR: {len(datasets)}." - - # Cap results - shown = matched[:limit] - - lines = [f"Found **{total_matched}** matching datasets (showing {len(shown)}):\n"] - for ds in shown: - lines.append(_format_summary(ds)) - - if total_matched > limit: - lines.append( - f"\n*{total_matched - limit} more results not shown. Narrow your search or increase limit.*" - ) - - return "\n".join(lines) - - -@tool -def get_nemar_dataset_details(dataset_id: str) -> str: - """Get comprehensive metadata for a specific NEMAR dataset. - - Retrieves full information including description, citation, licensing, - experimental details, and README content. - - Args: - dataset_id: Dataset identifier, e.g. "ds000248" or "ds005697". - - Returns: - Formatted markdown string with complete dataset information, - including OpenNeuro link, DOI, authors, license, and README. - """ - # Basic input validation - if not dataset_id or not re.match(r"^ds\d{4,6}$", dataset_id): - return f"Invalid dataset ID '{dataset_id}'. Expected format: ds000248 (ds + 4-6 digits)." - - url = f"{NEMAR_API_BASE}/datasetid" - payload = {"table_name": TABLE_NAME, "dataset_id": dataset_id} - - try: - response = httpx.request("GET", url, json=payload, timeout=30.0) - response.raise_for_status() - data = response.json() - - entry = data.get("entry", {}) - if not entry: - return f"Dataset '{dataset_id}' not found on NEMAR." - - # entry is {"0": {...}} for single results - ds = next(iter(entry.values())) - except httpx.HTTPError as e: - logger.warning("NEMAR API error for dataset %s: %s", dataset_id, e) - return f"Failed to fetch dataset {dataset_id} from NEMAR: {e}" - except (ValueError, KeyError, StopIteration) as e: - logger.warning("Failed to parse NEMAR response for %s: %s", dataset_id, e) - return f"Failed to parse NEMAR response for dataset {dataset_id}." - except Exception: - logger.exception("Unexpected error fetching NEMAR dataset %s", dataset_id) - return f"Failed to fetch dataset {dataset_id}. Please try again later." - - ds_id = ds.get("id", dataset_id) - name = ds.get("name", ds_id) - openneuro_url = f"https://openneuro.org/datasets/{ds_id}" - nemar_url = f"https://nemar.org/dataexplorer/detail?dataset_id={ds_id}" - - # Build formatted output - lines = [ - f"# {name}", - "", - f"**Dataset ID:** {ds_id}", - f"**NEMAR:** {nemar_url}", - f"**OpenNeuro:** {openneuro_url}", - ] - - doi = ds.get("DatasetDOI", "") - if doi: - lines.append(f"**DOI:** {doi}") - - lines.append("") - - # Authors (may use ===NEMAR-SEP=== or comma-separated) - authors = ds.get("Authors", "") - if authors: - author_list = _parse_sep_field(authors) if NEMAR_SEP in authors else [authors] - lines.append(f"**Authors:** {', '.join(author_list)}") - - # License - license_val = ds.get("License", "") - if license_val: - lines.append(f"**License:** {license_val}") - - # BIDS version - bids_ver = ds.get("BIDSVersion", "") - if bids_ver: - lines.append(f"**BIDS Version:** {bids_ver}") - - lines.append("") - - # Data characteristics - lines.append("## Data Characteristics") - lines.append("") - modalities = ds.get("modalities", "N/A") or "N/A" - tasks = ds.get("tasks", "N/A") or "N/A" - participants = ds.get("participants", 0) or 0 - sessions = ds.get("sessionsNum", 0) or 0 - total_files = ds.get("totalFiles", 0) or 0 - size = ds.get("byte_size_format", "unknown") or "unknown" - age_min = ds.get("age_min", 0) or 0 - age_max = ds.get("age_max", 0) or 0 - - lines.append(f"- **Modalities:** {modalities}") - lines.append(f"- **Tasks:** {tasks}") - lines.append(f"- **Participants:** {participants}") - lines.append(f"- **Sessions:** {sessions}") - lines.append(f"- **Total files:** {total_files}") - lines.append(f"- **Size:** {size}") - - if age_min or age_max: - lines.append(f"- **Age range:** {age_min}-{age_max}") - - # HED annotation - hed_ver = ds.get("HEDVersion", "") - has_hed_annotation = ds.get("hedAnnotation", 0) == 1 - if has_hed_annotation and hed_ver: - lines.append(f"- **HED annotations:** Yes (version {hed_ver})") - elif has_hed_annotation: - lines.append("- **HED annotations:** Yes") - else: - lines.append("- **HED annotations:** No") - - # Version info - snapshot = ds.get("latestSnapshot", "") - if snapshot: - lines.append(f"- **Latest version:** {snapshot}") - - # References and links - refs = ds.get("ReferencesAndLinks", "") - if refs: - ref_list = _parse_sep_field(refs) - if ref_list: - lines.append("") - lines.append("## References") - for ref in ref_list: - lines.append(f"- {ref}") - - # Funding - funding = ds.get("Funding", "") - if funding: - fund_list = _parse_sep_field(funding) - if fund_list: - lines.append("") - lines.append("## Funding") - for funder in fund_list: - lines.append(f"- {funder}") - - # Acknowledgements - ack = ds.get("Acknowledgements", "") - if ack: - lines.append("") - lines.append(f"## Acknowledgements\n\n{ack}") - - # How to acknowledge - how_to_ack = ds.get("HowToAcknowledge", "") - if how_to_ack: - lines.append("") - lines.append(f"## How to Acknowledge\n\n{how_to_ack}") - - # README (truncated) - readme = ds.get("readme", "") - if readme: - lines.append("") - lines.append("## README") - lines.append("") - if len(readme) > 1500: - lines.append(readme[:1500] + "\n\n*[README truncated; see OpenNeuro for full text]*") - else: - lines.append(readme) - - return "\n".join(lines) - - -__all__ = ["search_nemar_datasets", "get_nemar_dataset_details"] diff --git a/src/tools/mcp_client.py b/src/tools/mcp_client.py new file mode 100644 index 00000000..9a7df2ab --- /dev/null +++ b/src/tools/mcp_client.py @@ -0,0 +1,162 @@ +"""Model Context Protocol (MCP) client: turn a remote MCP server's tools into +LangChain tools the assistant can call. + +WHY THIS IS DIRECT SDK USAGE AND NOT `langchain-mcp-adapters`. That package +would be the obvious choice and it cannot be used: it pins `mcp<2.0.0`, and the +2026-07-28 protocol revision this is built against only exists in `mcp` 2.x. So +this module does the wrapping itself, which is about sixty lines. + +TWO DESIGN POINTS WORTH READING BEFORE CHANGING ANYTHING HERE. + +**Discovery runs in a worker thread, and that is not a workaround.** Tools are +assembled in `CommunityAssistant.__init__`, a SYNCHRONOUS constructor, which the +FastAPI app may itself call from inside a running event loop. `asyncio.run` +raises `RuntimeError` there ("cannot be called from a running event loop"), and +there is no correct synchronous way to await from inside a live loop on the same +thread. A dedicated thread with its own loop is correct whether or not the caller +has one, so this works identically at import time, in a script, and inside a +request handler. Tool INVOCATION needs none of this: it is a plain coroutine that +LangChain awaits on the caller's own loop. + +**A session per call is the right shape for this server, not laziness.** The +NEMAR MCP server is stateless by design: no session id, nothing to keep alive, +and `GET`/`DELETE` on the endpoint are 405 because there is no stream to resume. +Connect-call-close therefore costs one round trip and removes every piece of +lifecycle management a long-lived connection would need -- reconnection, +liveness, and a shared object whose failure mode is every tool breaking at once. +Do not "optimize" this into a persistent client without first checking that the +server has something to persist. + +**Failure degrades the assistant, it never breaks it.** An unreachable server +yields an empty tool list and a log line, matching `_load_plugin_tools`' contract +in `src/assistants/community.py`. An assistant that cannot start because someone +else's host is down is worse than one that is missing a few tools. +""" + +from __future__ import annotations + +import asyncio +import logging +from concurrent.futures import ThreadPoolExecutor +from typing import TYPE_CHECKING, Any + +from langchain_core.tools import BaseTool, StructuredTool + +if TYPE_CHECKING: + from src.core.config.community import McpServer + +logger = logging.getLogger(__name__) + +#: How long to wait for a server to list its tools before giving up and starting +#: without them. Discovery blocks the assistant's constructor, so this is a +#: startup-latency budget, not a network timeout: a slow server must not hold up +#: an app boot. +DISCOVERY_TIMEOUT_S = 20.0 + +#: How long a single tool call may take. Generous, because a tool may legitimately +#: read a remote index or render an image, but not unbounded. +CALL_TIMEOUT_S = 60.0 + + +def _text_of(result: Any) -> str: + """Join every text block of a `CallToolResult`. + + Joined rather than "the first one" or "the last one": a multi-block answer + otherwise reaches the model as an arbitrary fragment of itself. + """ + parts: list[str] = [] + for block in getattr(result, "content", None) or []: + text = getattr(block, "text", None) + if text: + parts.append(text) + return "\n".join(parts) + + +def _payload_of(result: Any) -> Any: + """What the model should see for a tool result. + + `structured_content` when the server sent it, because these tools return + structured JSON and a model reasons better over the object than over its + rendering. Falls back to joined text. + + An `is_error` result is returned as TEXT rather than raised. The server's + error messages are written to be read -- they name the cap that was exceeded, + or the public URL to fetch instead -- so handing that sentence to the model + lets it correct itself, where an exception would just end the turn. + """ + if getattr(result, "is_error", False): + return f"The tool reported an error: {_text_of(result) or 'no detail given'}" + structured = getattr(result, "structured_content", None) + if structured is not None: + return structured + return _text_of(result) + + +def _wrap_tool(server: McpServer, url: str, mcp_tool: Any) -> BaseTool: + """One MCP tool as a LangChain `StructuredTool`. + + The name is prefixed with the server's name (`nemar_search_datasets`) so two + servers offering a `search` cannot collide in one assistant's tool list. + """ + tool_name = mcp_tool.name + + async def _call(**kwargs: Any) -> Any: + # Imported here, not at module scope: this module is imported during + # config loading, and a missing optional dependency should surface as + # "MCP tools unavailable" from the loader below rather than as an + # ImportError that takes the whole app down. + from mcp import Client + + async with Client(url) as client: + result = await asyncio.wait_for( + client.call_tool(tool_name, kwargs), timeout=CALL_TIMEOUT_S + ) + return _payload_of(result) + + return StructuredTool( + name=f"{server.name}_{tool_name}", + description=mcp_tool.description or f"{tool_name} on the {server.name} MCP server", + # The server's own JSON Schema, verbatim. Not re-derived into a pydantic + # model: the server is the authority on what it accepts, and every + # translation step is a chance to disagree with it. + args_schema=mcp_tool.input_schema, + coroutine=_call, + ) + + +async def _discover(server: McpServer, url: str) -> list[BaseTool]: + from mcp import Client + + async with Client(url) as client: + listed = await asyncio.wait_for(client.list_tools(), timeout=DISCOVERY_TIMEOUT_S) + tools = [_wrap_tool(server, url, mcp_tool) for mcp_tool in listed.tools] + logger.info("Discovered %d tool(s) from MCP server %s at %s", len(tools), server.name, url) + return tools + + +def discover_mcp_tools(server: McpServer) -> list[BaseTool]: + """Connect to `server`, list its tools, and return them as LangChain tools. + + Safe to call from synchronous code whether or not an event loop is already + running on the calling thread (see the module docstring). Returns `[]` and + logs on any failure; never raises. + """ + if server.url is None: + # `command`-style (stdio) servers are a different transport and nothing + # configures one today. Declining explicitly beats a confusing failure + # deeper in the SDK. + logger.warning( + "MCP server %s has no url; only remote (Streamable HTTP) servers are supported", + server.name, + ) + return [] + + url = str(server.url) + try: + # A dedicated thread with its own loop. `asyncio.run` here would raise if + # the caller already has a running loop, which the FastAPI app does. + with ThreadPoolExecutor(max_workers=1, thread_name_prefix="mcp-discover") as pool: + return pool.submit(lambda: asyncio.run(_discover(server, url))).result() + except Exception as exc: # noqa: BLE001 - degrade, never break the assistant + logger.error("Could not load tools from MCP server %s at %s: %s", server.name, url, exc) + return [] diff --git a/tests/test_assistants/test_nemar_mcp_wiring.py b/tests/test_assistants/test_nemar_mcp_wiring.py new file mode 100644 index 00000000..bf4eaac9 --- /dev/null +++ b/tests/test_assistants/test_nemar_mcp_wiring.py @@ -0,0 +1,98 @@ +"""The NEMAR assistant really loads its tools from the MCP server. + +Separate from `tests/test_tools/test_mcp_client.py`, which proves the client +works against a server. This proves the WIRING: that the real +`src/assistants/nemar/config.yaml` reaches `_load_mcp_tools`, and that the tool +names the assistant ends up with are the ones its system prompt tells the model +to call. A prefix change or a config typo would leave every client test green and +the assistant still broken, which is exactly the gap a helper-level test cannot +see. +""" + +from __future__ import annotations + +from functools import lru_cache +from pathlib import Path + +import pytest + +from src.assistants.community import CommunityAssistant +from src.core.config.community import CommunityConfig + +# Resolved from this file, not from the working directory, so the test does not +# quietly depend on pytest's rootdir. +NEMAR_CONFIG_PATH = ( + Path(__file__).resolve().parents[2] / "src" / "assistants" / "nemar" / "config.yaml" +) + + +@lru_cache(maxsize=1) +def _nemar_config() -> CommunityConfig: + """The real shipped config, read from disk. Not a fixture built in the test: + the point is that the file the app loads is the one asserted on.""" + return CommunityConfig.from_yaml(NEMAR_CONFIG_PATH) + + +class TestNemarConfig: + """These run offline: they read the config, not the network.""" + + def test_config_declares_the_mcp_server(self) -> None: + extensions = _nemar_config().extensions + assert extensions is not None + assert [s.name for s in extensions.mcp_servers] == ["nemar"] + assert str(extensions.mcp_servers[0].url) == "https://mcp.nemar.org/mcp" + + def test_the_dead_python_plugins_are_gone(self) -> None: + """`search_nemar_datasets` and `get_nemar_dataset_details` called + nemar.org/api/dataexplorer/datapipeline/..., which returns 404 since the + legacy site was retired. The MCP server replaces them; if they come back, + the assistant is calling a dead endpoint again.""" + extensions = _nemar_config().extensions + assert extensions is not None + assert extensions.python_plugins == [] + + def test_the_prompt_names_the_prefixed_tools(self) -> None: + """The prompt has to use the names the loader actually produces + (`_`), or the model will call tools that do not exist.""" + prompt = _nemar_config().system_prompt + for tool in ("nemar_search_datasets", "nemar_describe_dataset", "nemar_read_window"): + assert tool in prompt + # And must not still describe the removed ones. + assert "search_nemar_datasets" not in prompt + assert "get_nemar_dataset_details" not in prompt + + def test_the_prompt_does_not_point_at_the_retired_site(self) -> None: + prompt = _nemar_config().system_prompt + assert "dataexplorer" not in prompt + assert "nemar.org/dataset/" in prompt + + +@pytest.mark.network +class TestNemarToolLoading: + """The real thing, against the real server. Deselected in CI.""" + + def test_load_mcp_tools_returns_the_six_nemar_tools(self) -> None: + # `_load_mcp_tools` does not touch instance state, so it can be exercised + # without standing up a model -- and it is the exact method the + # constructor calls. + tools = CommunityAssistant._load_mcp_tools(None, _nemar_config()) # type: ignore[arg-type] + assert {t.name for t in tools} == { + "nemar_search_datasets", + "nemar_describe_dataset", + "nemar_list_recordings", + "nemar_get_events", + "nemar_render_overview", + "nemar_read_window", + } + + def test_every_prompt_named_tool_actually_exists(self) -> None: + """The check that matters: no gap between what the prompt promises and + what the server provides.""" + tools = CommunityAssistant._load_mcp_tools(None, _nemar_config()) # type: ignore[arg-type] + available = {t.name for t in tools} + prompt = _nemar_config().system_prompt + named = {name for name in available if name in prompt} + # Every tool the prompt names is available... + assert named <= available + # ...and the prompt really does name the ladder, not just one of them. + assert len(named) >= 6 diff --git a/tests/test_tools/test_mcp_client.py b/tests/test_tools/test_mcp_client.py new file mode 100644 index 00000000..57d31bd2 --- /dev/null +++ b/tests/test_tools/test_mcp_client.py @@ -0,0 +1,286 @@ +"""Tests for the MCP client runtime. + +NO MOCKS. Every test here runs a REAL MCP server -- `mcp`'s own `MCPServer` over +Streamable HTTP, on a real socket, driven by the real `mcp` client through the +real LangChain tool wrappers. The only thing stood up specially is the server's +tool bodies, which is the fixture's subject, not a substitute for one. + +That matters most for the two things this module actually has to get right: + + 1. Discovery works from a synchronous caller EVEN WHEN an event loop is already + running on that thread, because `CommunityAssistant.__init__` is synchronous + and FastAPI calls it from inside a loop. A mock cannot demonstrate this; only + really running it can. + 2. An unreachable server degrades to an empty tool list rather than raising, + because an assistant that cannot start when someone else's host is down is + worse than one missing a few tools. +""" + +from __future__ import annotations + +import asyncio +import threading +import time +from collections.abc import Iterator +from typing import Any + +import httpx +import pytest +from mcp.server import MCPServer +from mcp.types import CallToolResult, TextContent + +from src.core.config.community import McpServer +from src.tools.mcp_client import discover_mcp_tools + +# -------------------------------------------------------------------------- +# A real MCP server on a real port. +# -------------------------------------------------------------------------- + + +def _build_server() -> MCPServer: + srv = MCPServer(name="fixture-server", version="0.0.1") + + @srv.tool(structured_output=False) + def echo_dataset(dataset_id: str, limit: int = 10) -> CallToolResult: + """Echo back what it was given, as structured content.""" + return CallToolResult( + content=[TextContent(type="text", text=f"echo {dataset_id}")], + structuredContent={"dataset_id": dataset_id, "limit": limit}, + ) + + @srv.tool(structured_output=False) + def text_only() -> CallToolResult: + """Answer with text blocks and no structured content.""" + return CallToolResult( + content=[ + TextContent(type="text", text="first block"), + TextContent(type="text", text="second block"), + ] + ) + + @srv.tool(structured_output=False) + def always_refuses() -> CallToolResult: + """Refuse the way the real server refuses: isError plus an explanation.""" + return CallToolResult( + content=[TextContent(type="text", text="declines: over the 60 s cap")], + isError=True, + ) + + return srv + + +@pytest.fixture(scope="module") +def mcp_url() -> Iterator[str]: + """A real MCP server on a background thread, torn down after the module.""" + import uvicorn + + app = _build_server().streamable_http_app() + config = uvicorn.Config(app, host="127.0.0.1", port=0, log_level="error") + server = uvicorn.Server(config) + thread = threading.Thread(target=server.run, daemon=True) + thread.start() + + # Wait for the socket to be assigned rather than sleeping a guessed amount. + deadline = time.monotonic() + 30 + while time.monotonic() < deadline: + if server.started and server.servers: + break + time.sleep(0.05) + else: # pragma: no cover - only on a broken environment + raise RuntimeError("fixture MCP server did not start") + + port = server.servers[0].sockets[0].getsockname()[1] + url = f"http://127.0.0.1:{port}/mcp" + + # Confirm it really answers before any test runs, so a failure here is + # reported as a fixture problem rather than as every test failing oddly. + for _ in range(60): + try: + httpx.post(url, json={}, timeout=2.0) + break + except httpx.HTTPError: + time.sleep(0.05) + + yield url + + server.should_exit = True + thread.join(timeout=10) + + +def _server(url: str, name: str = "fixture") -> McpServer: + return McpServer(name=name, url=url) + + +# -------------------------------------------------------------------------- +# Discovery +# -------------------------------------------------------------------------- + + +class TestDiscovery: + def test_returns_a_langchain_tool_per_mcp_tool(self, mcp_url: str) -> None: + tools = discover_mcp_tools(_server(mcp_url)) + assert {t.name for t in tools} == { + "fixture_echo_dataset", + "fixture_text_only", + "fixture_always_refuses", + } + + def test_names_are_prefixed_with_the_server_name(self, mcp_url: str) -> None: + """So two servers offering the same tool cannot collide in one assistant.""" + tools = discover_mcp_tools(_server(mcp_url, name="other")) + assert all(t.name.startswith("other_") for t in tools) + + def test_args_schema_is_the_servers_own_input_schema(self, mcp_url: str) -> None: + """Passed through verbatim rather than re-derived: the server is the + authority on what it accepts, and every translation step is a chance to + disagree with it.""" + tool = next( + t for t in discover_mcp_tools(_server(mcp_url)) if t.name.endswith("echo_dataset") + ) + assert isinstance(tool.args_schema, dict) + assert set(tool.args_schema["properties"]) == {"dataset_id", "limit"} + assert tool.args_schema["required"] == ["dataset_id"] + + def test_description_comes_from_the_server(self, mcp_url: str) -> None: + tool = next( + t for t in discover_mcp_tools(_server(mcp_url)) if t.name.endswith("echo_dataset") + ) + assert "Echo back" in tool.description + + def test_works_from_inside_a_running_event_loop(self, mcp_url: str) -> None: + """The case a naive `asyncio.run` breaks on, and the reason discovery uses + a worker thread: `CommunityAssistant.__init__` is synchronous and the + FastAPI app calls it from inside a live loop.""" + + async def caller() -> list[Any]: + # A synchronous call, made from inside a running loop. + return discover_mcp_tools(_server(mcp_url)) + + tools = asyncio.run(caller()) + assert len(tools) == 3 + + def test_works_from_a_plain_synchronous_caller(self, mcp_url: str) -> None: + """The other half: no loop running at all.""" + assert len(discover_mcp_tools(_server(mcp_url))) == 3 + + +# -------------------------------------------------------------------------- +# Invocation +# -------------------------------------------------------------------------- + + +class TestInvocation: + async def test_round_trips_arguments_and_structured_result(self, mcp_url: str) -> None: + tool = next( + t for t in discover_mcp_tools(_server(mcp_url)) if t.name.endswith("echo_dataset") + ) + result = await tool.ainvoke({"dataset_id": "nm000329", "limit": 3}) + assert result == {"dataset_id": "nm000329", "limit": 3} + + async def test_a_default_is_the_servers_default(self, mcp_url: str) -> None: + tool = next( + t for t in discover_mcp_tools(_server(mcp_url)) if t.name.endswith("echo_dataset") + ) + result = await tool.ainvoke({"dataset_id": "nm000329"}) + assert result == {"dataset_id": "nm000329", "limit": 10} + + async def test_text_only_result_joins_every_block(self, mcp_url: str) -> None: + """Joined, not "the first" or "the last": a multi-block answer otherwise + reaches the model as an arbitrary fragment of itself.""" + tool = next(t for t in discover_mcp_tools(_server(mcp_url)) if t.name.endswith("text_only")) + result = await tool.ainvoke({}) + assert result == "first block\nsecond block" + + async def test_a_refusal_comes_back_as_text_not_an_exception(self, mcp_url: str) -> None: + """The server's refusals name a cap or a workaround, so handing that + sentence to the model lets it correct itself; raising would end the turn.""" + tool = next( + t for t in discover_mcp_tools(_server(mcp_url)) if t.name.endswith("always_refuses") + ) + result = await tool.ainvoke({}) + assert isinstance(result, str) + assert "over the 60 s cap" in result + + async def test_an_input_the_schema_rejects_does_not_raise_out(self, mcp_url: str) -> None: + """A schema rejection is an `isError` result on this SDK, so it takes the + same path as any other refusal and reaches the model as readable text.""" + tool = next( + t for t in discover_mcp_tools(_server(mcp_url)) if t.name.endswith("echo_dataset") + ) + result = await tool.ainvoke({"dataset_id": "nm000329", "limit": "not-an-int"}) + assert isinstance(result, str) + assert "error" in result.lower() + + +# -------------------------------------------------------------------------- +# Degradation +# -------------------------------------------------------------------------- + + +class TestDegradation: + def test_an_unreachable_server_yields_no_tools_and_does_not_raise(self) -> None: + # Port 1 on localhost: nothing listens, and the connection is refused + # immediately rather than hanging, so this does not depend on a timeout. + tools = discover_mcp_tools(McpServer(name="dead", url="http://127.0.0.1:1/mcp")) + assert tools == [] + + def test_a_url_that_is_not_an_mcp_server_yields_no_tools(self, mcp_url: str) -> None: + # The right host, the wrong path: the descriptor route, not the transport. + base = mcp_url.rsplit("/mcp", 1)[0] + assert discover_mcp_tools(McpServer(name="wrong-path", url=f"{base}/nope")) == [] + + def test_a_command_style_server_is_declined_explicitly(self) -> None: + """Only Streamable HTTP is supported; a stdio server is declined with a log + rather than failing somewhere deeper in the SDK.""" + assert discover_mcp_tools(McpServer(name="stdio", command=["some-server"])) == [] + + +# -------------------------------------------------------------------------- +# Against the real NEMAR server. Deselected in CI (`-m "not network"`). +# -------------------------------------------------------------------------- + + +@pytest.mark.network +class TestAgainstProductionNemar: + """The end-to-end proof, run on demand rather than in CI. + + The fixture-server tests above prove the wrapper. They cannot prove that + NEMAR's actual server still presents the tools this assistant's prompt tells + the model about -- a rename or a removal there would break the assistant + while every test above stayed green. This closes that gap when someone runs + it: `uv run pytest tests/test_tools/test_mcp_client.py -m network`. + """ + + URL = "https://mcp.nemar.org/mcp" + + def test_discovers_the_documented_tool_set(self) -> None: + tools = discover_mcp_tools(McpServer(name="nemar", url=self.URL)) + assert {t.name for t in tools} == { + "nemar_search_datasets", + "nemar_describe_dataset", + "nemar_list_recordings", + "nemar_get_events", + "nemar_render_overview", + "nemar_read_window", + } + + async def test_search_returns_real_datasets(self) -> None: + tools = discover_mcp_tools(McpServer(name="nemar", url=self.URL)) + search = next(t for t in tools if t.name == "nemar_search_datasets") + result = await search.ainvoke({"query": "motor imagery", "limit": 2}) + assert isinstance(result, dict) + assert result["count"] > 0 + assert len(result["results"]) == 2 + # Dataset ids are nm/on plus six digits, not OpenNeuro ds accessions -- + # the assistant's prompt says so, so it is worth holding the server to. + for row in result["results"]: + assert row["dataset_id"][:2] in {"nm", "on"} + + async def test_a_refusal_arrives_as_readable_text(self) -> None: + """The behaviour the prompt tells the model to relay: a declined request + explains itself and names a workaround.""" + tools = discover_mcp_tools(McpServer(name="nemar", url=self.URL)) + describe = next(t for t in tools if t.name == "nemar_describe_dataset") + result = await describe.ainvoke({"dataset_id": "nm999999"}) + assert isinstance(result, str) + assert "not found" in result.lower() diff --git a/tests/test_tools/test_nemar_tools.py b/tests/test_tools/test_nemar_tools.py deleted file mode 100644 index cbefa7fb..00000000 --- a/tests/test_tools/test_nemar_tools.py +++ /dev/null @@ -1,290 +0,0 @@ -"""Tests for NEMAR dataset discovery tools. - -These tests call the real NEMAR API to ensure tools work correctly. -NO MOCKS - we test against the actual service. -""" - -import pytest - -from src.assistants.nemar import tools as nemar_tools_module -from src.assistants.nemar.tools import ( - _fetch_all_datasets, - _matches, - _parse_sep_field, - get_nemar_dataset_details, - search_nemar_datasets, -) - - -class TestParseHelpers: - """Tests for internal helper functions.""" - - def test_parse_sep_field_with_separator(self): - """Test splitting multi-value fields with ===NEMAR-SEP=== delimiter.""" - value = "NIH R01===NEMAR-SEP===NSF BCS-123===NEMAR-SEP===ONR N00014" - result = _parse_sep_field(value) - assert result == ["NIH R01", "NSF BCS-123", "ONR N00014"] - - def test_parse_sep_field_single_value(self): - """Test that single values without separator return as-is.""" - result = _parse_sep_field("Single funding source") - assert result == ["Single funding source"] - - def test_parse_sep_field_empty(self): - """Test that empty string returns empty list.""" - assert _parse_sep_field("") == [] - - def test_parse_sep_field_strips_whitespace(self): - """Test that whitespace around values is stripped.""" - value = " A ===NEMAR-SEP=== B ===NEMAR-SEP=== C " - result = _parse_sep_field(value) - assert result == ["A", "B", "C"] - - def test_parse_sep_field_skips_empty_parts(self): - """Test that empty parts between separators are skipped.""" - value = "A===NEMAR-SEP======NEMAR-SEP===B" - result = _parse_sep_field(value) - assert result == ["A", "B"] - - -class TestMatches: - """Tests for the dataset filter matching logic.""" - - @pytest.fixture() - def sample_dataset(self): - return { - "id": "ds001234", - "name": "Visual attention EEG study", - "tasks": "attention, rest", - "modalities": "EEG", - "readme": "A study of visual attention in healthy adults.", - "Authors": "Jane Doe, John Smith", - "hedAnnotation": 0, - "participants": 30, - } - - def test_no_filters_matches_all(self, sample_dataset): - assert _matches(sample_dataset, None, None, None, None, None) is True - - def test_query_matches_name(self, sample_dataset): - assert _matches(sample_dataset, "visual", None, None, None, None) is True - - def test_query_matches_tasks(self, sample_dataset): - assert _matches(sample_dataset, "attention", None, None, None, None) is True - - def test_query_matches_readme(self, sample_dataset): - assert _matches(sample_dataset, "healthy adults", None, None, None, None) is True - - def test_query_matches_authors(self, sample_dataset): - assert _matches(sample_dataset, "Jane Doe", None, None, None, None) is True - - def test_query_case_insensitive(self, sample_dataset): - assert _matches(sample_dataset, "VISUAL", None, None, None, None) is True - - def test_query_no_match(self, sample_dataset): - assert _matches(sample_dataset, "nonexistent_term_xyz", None, None, None, None) is False - - def test_modality_filter_match(self, sample_dataset): - assert _matches(sample_dataset, None, "EEG", None, None, None) is True - - def test_modality_filter_no_match(self, sample_dataset): - assert _matches(sample_dataset, None, "MEG", None, None, None) is False - - def test_modality_filter_case_insensitive(self, sample_dataset): - assert _matches(sample_dataset, None, "eeg", None, None, None) is True - - def test_task_filter_match(self, sample_dataset): - assert _matches(sample_dataset, None, None, "rest", None, None) is True - - def test_task_filter_no_match(self, sample_dataset): - assert _matches(sample_dataset, None, None, "gonogo", None, None) is False - - def test_has_hed_true_no_annotation(self, sample_dataset): - assert _matches(sample_dataset, None, None, None, True, None) is False - - def test_has_hed_true_with_annotation(self, sample_dataset): - sample_dataset["hedAnnotation"] = 1 - assert _matches(sample_dataset, None, None, None, True, None) is True - - def test_has_hed_none_ignores_filter(self, sample_dataset): - assert _matches(sample_dataset, None, None, None, None, None) is True - - def test_min_participants_pass(self, sample_dataset): - assert _matches(sample_dataset, None, None, None, None, 20) is True - - def test_min_participants_fail(self, sample_dataset): - assert _matches(sample_dataset, None, None, None, None, 50) is False - - def test_combined_filters(self, sample_dataset): - """Test that multiple filters are ANDed together.""" - assert _matches(sample_dataset, "visual", "EEG", "attention", None, 10) is True - assert _matches(sample_dataset, "visual", "MEG", "attention", None, 10) is False - - -@pytest.mark.network -class TestFetchAllDatasets: - """Tests for the NEMAR API fetch function.""" - - def test_fetch_returns_list(self): - """Test that we get a non-empty list of datasets.""" - datasets = _fetch_all_datasets() - assert isinstance(datasets, list) - assert len(datasets) > 0 - - def test_fetch_dataset_has_required_fields(self): - """Test that datasets have the expected schema fields.""" - datasets = _fetch_all_datasets() - ds = datasets[0] - - required_fields = ["id", "name", "modalities", "tasks", "participants"] - for field in required_fields: - assert field in ds, f"Missing field: {field}" - - def test_fetch_dataset_count_reasonable(self): - """Test that dataset count is in a reasonable range.""" - datasets = _fetch_all_datasets() - # NEMAR has ~485 datasets as of 2025; allow for growth - assert len(datasets) >= 100 - assert len(datasets) < 5000 - - -@pytest.mark.network -class TestSearchNemarDatasets: - """Tests for the search_nemar_datasets tool against the live API.""" - - def test_search_no_filters_returns_results(self): - """Test that searching without filters returns datasets.""" - result = search_nemar_datasets.invoke({"limit": 5}) - assert "Found **" in result - assert "ds0" in result # Dataset IDs start with ds0 - - def test_search_by_modality_eeg(self): - """Test filtering by EEG modality.""" - result = search_nemar_datasets.invoke({"modality_filter": "EEG", "limit": 5}) - assert "Found **" in result - assert "EEG" in result - - def test_search_by_modality_meg(self): - """Test filtering by MEG modality.""" - result = search_nemar_datasets.invoke({"modality_filter": "MEG", "limit": 5}) - assert "Found **" in result - assert "MEG" in result - - def test_search_by_text_query(self): - """Test text search across dataset fields.""" - result = search_nemar_datasets.invoke({"query": "rest", "limit": 5}) - assert "Found **" in result - - def test_search_has_hed(self): - """Test filtering for HED-annotated datasets.""" - result = search_nemar_datasets.invoke({"has_hed": True, "limit": 50}) - assert "Found **" in result - # There are a small number of HED-annotated datasets - assert "ds0" in result - - def test_search_min_participants(self): - """Test filtering by minimum participant count.""" - result = search_nemar_datasets.invoke({"min_participants": 100, "limit": 5}) - assert "Found **" in result - - def test_search_no_results(self): - """Test that a query with no matches returns helpful message.""" - result = search_nemar_datasets.invoke( - {"query": "zzz_nonexistent_term_that_matches_nothing_xyz"} - ) - assert "No datasets found" in result - assert "Total datasets in NEMAR" in result - - def test_search_limit_respected(self): - """Test that the limit parameter caps results.""" - result = search_nemar_datasets.invoke({"limit": 3}) - assert "(showing 3)" in result - - def test_search_combined_filters(self): - """Test combining text search with modality filter.""" - result = search_nemar_datasets.invoke( - {"query": "rest", "modality_filter": "EEG", "limit": 5} - ) - # Should either find results or report no matches - assert "Found **" in result or "No datasets found" in result - - -class TestGetNemarDatasetDetails: - """Tests for the get_nemar_dataset_details tool against the live API.""" - - @pytest.mark.network - def test_get_known_dataset(self): - """Test retrieving a known dataset (ds000248 - MNE sample data).""" - result = get_nemar_dataset_details.invoke({"dataset_id": "ds000248"}) - - assert "ds000248" in result - assert "openneuro.org/datasets/ds000248" in result - assert "nemar.org/dataexplorer/detail" in result - assert "Data Characteristics" in result - - @pytest.mark.network - def test_get_dataset_has_metadata(self): - """Test that retrieved dataset contains expected metadata sections.""" - result = get_nemar_dataset_details.invoke({"dataset_id": "ds000248"}) - - assert "Modalities:" in result - assert "Tasks:" in result - assert "Participants:" in result - assert "HED annotations:" in result - - @pytest.mark.network - def test_get_dataset_has_links(self): - """Test that dataset details include OpenNeuro and NEMAR links.""" - result = get_nemar_dataset_details.invoke({"dataset_id": "ds000248"}) - - assert "https://openneuro.org/datasets/ds000248" in result - assert "https://nemar.org/dataexplorer/detail?dataset_id=ds000248" in result - - @pytest.mark.network - def test_get_nonexistent_dataset(self): - """Test that a nonexistent dataset returns a clear message.""" - result = get_nemar_dataset_details.invoke({"dataset_id": "ds999999"}) - assert "not found" in result - - @pytest.mark.network - def test_get_dataset_with_hed(self): - """Test retrieving a dataset known to have HED annotations.""" - # ds002578 has HED annotations - result = get_nemar_dataset_details.invoke({"dataset_id": "ds002578"}) - assert "HED annotations:" in result - assert "Yes" in result - - def test_get_invalid_dataset_id_format(self): - """Test that invalid dataset IDs are rejected before API call.""" - result = get_nemar_dataset_details.invoke({"dataset_id": "invalid"}) - assert "Invalid dataset ID" in result - - def test_get_empty_dataset_id(self): - """Test that empty dataset ID is rejected.""" - result = get_nemar_dataset_details.invoke({"dataset_id": ""}) - assert "Invalid dataset ID" in result - - def test_get_dataset_id_too_short(self): - """Test that dataset ID with too few digits is rejected.""" - result = get_nemar_dataset_details.invoke({"dataset_id": "ds12"}) - assert "Invalid dataset ID" in result - - -@pytest.mark.network -class TestCaching: - """Tests for the TTL cache on _fetch_all_datasets.""" - - def test_cache_returns_same_result(self): - """Test that consecutive calls return cached data.""" - result1 = _fetch_all_datasets() - result2 = _fetch_all_datasets() - # Same object reference means cache was used - assert result1 is result2 - - def test_cache_can_be_cleared(self): - """Test that clearing the cache forces a fresh fetch.""" - _fetch_all_datasets() # populate cache - nemar_tools_module._datasets_cache = [] - nemar_tools_module._cache_timestamp = 0.0 - result = _fetch_all_datasets() - assert len(result) > 0 diff --git a/uv.lock b/uv.lock index 8824ba34..5dc1b9b2 100644 --- a/uv.lock +++ b/uv.lock @@ -4,7 +4,8 @@ requires-python = ">=3.11" resolution-markers = [ "python_full_version >= '3.14'", "python_full_version == '3.13.*'", - "python_full_version < '3.13'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", + "(python_full_version < '3.13' and sys_platform != 'emscripten') or (python_full_version < '3.12' and sys_platform == 'emscripten')", ] [[package]] @@ -811,7 +812,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1f/cb/48e964c452ca2b92175a9b2dca037a553036cb053ba69e284650ce755f13/greenlet-3.3.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:e29f3018580e8412d6aaf5641bb7745d38c85228dacf51a73bd4e26ddf2a6a8e", size = 274908, upload-time = "2025-12-04T14:23:26.435Z" }, { url = "https://files.pythonhosted.org/packages/28/da/38d7bff4d0277b594ec557f479d65272a893f1f2a716cad91efeb8680953/greenlet-3.3.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a687205fb22794e838f947e2194c0566d3812966b41c78709554aa883183fb62", size = 577113, upload-time = "2025-12-04T14:50:05.493Z" }, { url = "https://files.pythonhosted.org/packages/3c/f2/89c5eb0faddc3ff014f1c04467d67dee0d1d334ab81fadbf3744847f8a8a/greenlet-3.3.0-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4243050a88ba61842186cb9e63c7dfa677ec146160b0efd73b855a3d9c7fcf32", size = 590338, upload-time = "2025-12-04T14:57:41.136Z" }, - { url = "https://files.pythonhosted.org/packages/80/d7/db0a5085035d05134f8c089643da2b44cc9b80647c39e93129c5ef170d8f/greenlet-3.3.0-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:670d0f94cd302d81796e37299bcd04b95d62403883b24225c6b5271466612f45", size = 601098, upload-time = "2025-12-04T15:07:11.898Z" }, { url = "https://files.pythonhosted.org/packages/dc/a6/e959a127b630a58e23529972dbc868c107f9d583b5a9f878fb858c46bc1a/greenlet-3.3.0-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6cb3a8ec3db4a3b0eb8a3c25436c2d49e3505821802074969db017b87bc6a948", size = 590206, upload-time = "2025-12-04T14:26:01.254Z" }, { url = "https://files.pythonhosted.org/packages/48/60/29035719feb91798693023608447283b266b12efc576ed013dd9442364bb/greenlet-3.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2de5a0b09eab81fc6a382791b995b1ccf2b172a9fec934747a7a23d2ff291794", size = 1550668, upload-time = "2025-12-04T15:04:22.439Z" }, { url = "https://files.pythonhosted.org/packages/0a/5f/783a23754b691bfa86bd72c3033aa107490deac9b2ef190837b860996c9f/greenlet-3.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4449a736606bd30f27f8e1ff4678ee193bc47f6ca810d705981cfffd6ce0d8c5", size = 1615483, upload-time = "2025-12-04T14:27:28.083Z" }, @@ -819,7 +819,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f8/0a/a3871375c7b9727edaeeea994bfff7c63ff7804c9829c19309ba2e058807/greenlet-3.3.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:b01548f6e0b9e9784a2c99c5651e5dc89ffcbe870bc5fb2e5ef864e9cc6b5dcb", size = 276379, upload-time = "2025-12-04T14:23:30.498Z" }, { url = "https://files.pythonhosted.org/packages/43/ab/7ebfe34dce8b87be0d11dae91acbf76f7b8246bf9d6b319c741f99fa59c6/greenlet-3.3.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:349345b770dc88f81506c6861d22a6ccd422207829d2c854ae2af8025af303e3", size = 597294, upload-time = "2025-12-04T14:50:06.847Z" }, { url = "https://files.pythonhosted.org/packages/a4/39/f1c8da50024feecd0793dbd5e08f526809b8ab5609224a2da40aad3a7641/greenlet-3.3.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e8e18ed6995e9e2c0b4ed264d2cf89260ab3ac7e13555b8032b25a74c6d18655", size = 607742, upload-time = "2025-12-04T14:57:42.349Z" }, - { url = "https://files.pythonhosted.org/packages/77/cb/43692bcd5f7a0da6ec0ec6d58ee7cddb606d055ce94a62ac9b1aa481e969/greenlet-3.3.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c024b1e5696626890038e34f76140ed1daf858e37496d33f2af57f06189e70d7", size = 622297, upload-time = "2025-12-04T15:07:13.552Z" }, { url = "https://files.pythonhosted.org/packages/75/b0/6bde0b1011a60782108c01de5913c588cf51a839174538d266de15e4bf4d/greenlet-3.3.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:047ab3df20ede6a57c35c14bf5200fcf04039d50f908270d3f9a7a82064f543b", size = 609885, upload-time = "2025-12-04T14:26:02.368Z" }, { url = "https://files.pythonhosted.org/packages/49/0e/49b46ac39f931f59f987b7cd9f34bfec8ef81d2a1e6e00682f55be5de9f4/greenlet-3.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2d9ad37fc657b1102ec880e637cccf20191581f75c64087a549e66c57e1ceb53", size = 1567424, upload-time = "2025-12-04T15:04:23.757Z" }, { url = "https://files.pythonhosted.org/packages/05/f5/49a9ac2dff7f10091935def9165c90236d8f175afb27cbed38fb1d61ab6b/greenlet-3.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:83cd0e36932e0e7f36a64b732a6f60c2fc2df28c351bae79fbaf4f8092fe7614", size = 1636017, upload-time = "2025-12-04T14:27:29.688Z" }, @@ -827,7 +826,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/02/2f/28592176381b9ab2cafa12829ba7b472d177f3acc35d8fbcf3673d966fff/greenlet-3.3.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:a1e41a81c7e2825822f4e068c48cb2196002362619e2d70b148f20a831c00739", size = 275140, upload-time = "2025-12-04T14:23:01.282Z" }, { url = "https://files.pythonhosted.org/packages/2c/80/fbe937bf81e9fca98c981fe499e59a3f45df2a04da0baa5c2be0dca0d329/greenlet-3.3.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9f515a47d02da4d30caaa85b69474cec77b7929b2e936ff7fb853d42f4bf8808", size = 599219, upload-time = "2025-12-04T14:50:08.309Z" }, { url = "https://files.pythonhosted.org/packages/c2/ff/7c985128f0514271b8268476af89aee6866df5eec04ac17dcfbc676213df/greenlet-3.3.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7d2d9fd66bfadf230b385fdc90426fcd6eb64db54b40c495b72ac0feb5766c54", size = 610211, upload-time = "2025-12-04T14:57:43.968Z" }, - { url = "https://files.pythonhosted.org/packages/79/07/c47a82d881319ec18a4510bb30463ed6891f2ad2c1901ed5ec23d3de351f/greenlet-3.3.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:30a6e28487a790417d036088b3bcb3f3ac7d8babaa7d0139edbaddebf3af9492", size = 624311, upload-time = "2025-12-04T15:07:14.697Z" }, { url = "https://files.pythonhosted.org/packages/fd/8e/424b8c6e78bd9837d14ff7df01a9829fc883ba2ab4ea787d4f848435f23f/greenlet-3.3.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:087ea5e004437321508a8d6f20efc4cfec5e3c30118e1417ea96ed1d93950527", size = 612833, upload-time = "2025-12-04T14:26:03.669Z" }, { url = "https://files.pythonhosted.org/packages/b5/ba/56699ff9b7c76ca12f1cdc27a886d0f81f2189c3455ff9f65246780f713d/greenlet-3.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ab97cf74045343f6c60a39913fa59710e4bd26a536ce7ab2397adf8b27e67c39", size = 1567256, upload-time = "2025-12-04T15:04:25.276Z" }, { url = "https://files.pythonhosted.org/packages/1e/37/f31136132967982d698c71a281a8901daf1a8fbab935dce7c0cf15f942cc/greenlet-3.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5375d2e23184629112ca1ea89a53389dddbffcf417dad40125713d88eb5f96e8", size = 1636483, upload-time = "2025-12-04T14:27:30.804Z" }, @@ -835,7 +833,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d7/7c/f0a6d0ede2c7bf092d00bc83ad5bafb7e6ec9b4aab2fbdfa6f134dc73327/greenlet-3.3.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:60c2ef0f578afb3c8d92ea07ad327f9a062547137afe91f38408f08aacab667f", size = 275671, upload-time = "2025-12-04T14:23:05.267Z" }, { url = "https://files.pythonhosted.org/packages/44/06/dac639ae1a50f5969d82d2e3dd9767d30d6dbdbab0e1a54010c8fe90263c/greenlet-3.3.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a5d554d0712ba1de0a6c94c640f7aeba3f85b3a6e1f2899c11c2c0428da9365", size = 646360, upload-time = "2025-12-04T14:50:10.026Z" }, { url = "https://files.pythonhosted.org/packages/e0/94/0fb76fe6c5369fba9bf98529ada6f4c3a1adf19e406a47332245ef0eb357/greenlet-3.3.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3a898b1e9c5f7307ebbde4102908e6cbfcb9ea16284a3abe15cab996bee8b9b3", size = 658160, upload-time = "2025-12-04T14:57:45.41Z" }, - { url = "https://files.pythonhosted.org/packages/93/79/d2c70cae6e823fac36c3bbc9077962105052b7ef81db2f01ec3b9bf17e2b/greenlet-3.3.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:dcd2bdbd444ff340e8d6bdf54d2f206ccddbb3ccfdcd3c25bf4afaa7b8f0cf45", size = 671388, upload-time = "2025-12-04T15:07:15.789Z" }, { url = "https://files.pythonhosted.org/packages/b8/14/bab308fc2c1b5228c3224ec2bf928ce2e4d21d8046c161e44a2012b5203e/greenlet-3.3.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5773edda4dc00e173820722711d043799d3adb4f01731f40619e07ea2750b955", size = 660166, upload-time = "2025-12-04T14:26:05.099Z" }, { url = "https://files.pythonhosted.org/packages/4b/d2/91465d39164eaa0085177f61983d80ffe746c5a1860f009811d498e7259c/greenlet-3.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ac0549373982b36d5fd5d30beb8a7a33ee541ff98d2b502714a09f1169f31b55", size = 1615193, upload-time = "2025-12-04T15:04:27.041Z" }, { url = "https://files.pythonhosted.org/packages/42/1b/83d110a37044b92423084d52d5d5a3b3a73cafb51b547e6d7366ff62eff1/greenlet-3.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d198d2d977460358c3b3a4dc844f875d1adb33817f0613f663a656f463764ccc", size = 1683653, upload-time = "2025-12-04T14:27:32.366Z" }, @@ -843,7 +840,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a0/66/bd6317bc5932accf351fc19f177ffba53712a202f9df10587da8df257c7e/greenlet-3.3.0-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:d6ed6f85fae6cdfdb9ce04c9bf7a08d666cfcfb914e7d006f44f840b46741931", size = 282638, upload-time = "2025-12-04T14:25:20.941Z" }, { url = "https://files.pythonhosted.org/packages/30/cf/cc81cb030b40e738d6e69502ccbd0dd1bced0588e958f9e757945de24404/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9125050fcf24554e69c4cacb086b87b3b55dc395a8b3ebe6487b045b2614388", size = 651145, upload-time = "2025-12-04T14:50:11.039Z" }, { url = "https://files.pythonhosted.org/packages/9c/ea/1020037b5ecfe95ca7df8d8549959baceb8186031da83d5ecceff8b08cd2/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:87e63ccfa13c0a0f6234ed0add552af24cc67dd886731f2261e46e241608bee3", size = 654236, upload-time = "2025-12-04T14:57:47.007Z" }, - { url = "https://files.pythonhosted.org/packages/69/cc/1e4bae2e45ca2fa55299f4e85854606a78ecc37fead20d69322f96000504/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2662433acbca297c9153a4023fe2161c8dcfdcc91f10433171cf7e7d94ba2221", size = 662506, upload-time = "2025-12-04T15:07:16.906Z" }, { url = "https://files.pythonhosted.org/packages/57/b9/f8025d71a6085c441a7eaff0fd928bbb275a6633773667023d19179fe815/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3c6e9b9c1527a78520357de498b0e709fb9e2f49c3a513afd5a249007261911b", size = 653783, upload-time = "2025-12-04T14:26:06.225Z" }, { url = "https://files.pythonhosted.org/packages/f6/c7/876a8c7a7485d5d6b5c6821201d542ef28be645aa024cfe1145b35c120c1/greenlet-3.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:286d093f95ec98fdd92fcb955003b8a3d054b4e2cab3e2707a5039e7b50520fd", size = 1614857, upload-time = "2025-12-04T15:04:28.484Z" }, { url = "https://files.pythonhosted.org/packages/4f/dc/041be1dff9f23dac5f48a43323cd0789cb798342011c19a248d9c9335536/greenlet-3.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c10513330af5b8ae16f023e8ddbfb486ab355d04467c4679c5cfe4659975dd9", size = 1676034, upload-time = "2025-12-04T14:27:33.531Z" }, @@ -951,6 +947,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, ] +[[package]] +name = "httpcore2" +version = "2.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11" }, + { name = "truststore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/be/ad/f4f0e57345f1870f3e8cb624e058d7eca6e5a27d33bcc3311d9b618734cd/httpcore2-2.12.0.tar.gz", hash = "sha256:9293522bba0aa7c4c8e9e3f040c16575bd8868e155a77fa30c7a9085a5eae648", size = 67548, upload-time = "2026-08-18T13:22:08.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/74/d370e55600d9bcfa0d9794b0166126d49291a3d2b20c268fc98c453a4948/httpcore2-2.12.0-py3-none-any.whl", hash = "sha256:7e04258ce01013d7d615e5b910a3b27fac937d7a95038227e79652b4ba3b4ceb", size = 83074, upload-time = "2026-08-18T13:22:05.854Z" }, +] + [[package]] name = "httptools" version = "0.7.1" @@ -1011,6 +1020,32 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" }, ] +[[package]] +name = "httpx2" +version = "2.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio", marker = "sys_platform != 'emscripten'" }, + { name = "httpcore2", marker = "sys_platform != 'emscripten'" }, + { name = "httpx2-jsfetch", marker = "python_full_version >= '3.12' and sys_platform == 'emscripten'" }, + { name = "idna" }, + { name = "truststore", marker = "sys_platform != 'emscripten'" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7f/f8/579a8b51e42e38ee32647df9f08aa25643ae788e275cc625b199829c4671/httpx2-2.12.0.tar.gz", hash = "sha256:7631fe9887a8a2275f4a2540e053aa670fcc50742864a9ae7c66e609fdcf12cf", size = 100040, upload-time = "2026-08-18T13:22:09.086Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/95/411ba65569158e862368917aaf56597f3e5fa3b91b0502919638465a08f3/httpx2-2.12.0-py3-none-any.whl", hash = "sha256:cc8b6eecb8661c146b8f89a60e97456ee086e91a784ed31ac450c3a9e613dd36", size = 95427, upload-time = "2026-08-18T13:22:06.834Z" }, +] + +[[package]] +name = "httpx2-jsfetch" +version = "1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/c4/0e5636363151a2a1795e0a77617168b9ca438e1748ec05fc9b5687f93d64/httpx2_jsfetch-1.0.tar.gz", hash = "sha256:70a0e3eabfef7cce5ad9c629f7d01ca05e418f586646f4ddf14782e4c1454c60", size = 6872, upload-time = "2026-08-07T00:13:07.492Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/43/832f631d32e4f1211caa2ba368317739fe71f0b8530e4c9d15dc454bac2a/httpx2_jsfetch-1.0-py3-none-any.whl", hash = "sha256:cb916b707601e69a07721aabc8f3f6659be3a6893bc1ff5c6f9e02241df2da32", size = 6382, upload-time = "2026-08-07T00:13:06.567Z" }, +] + [[package]] name = "huggingface-hub" version = "1.2.4" @@ -1043,11 +1078,11 @@ wheels = [ [[package]] name = "idna" -version = "3.11" +version = "3.19" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/f7/abb373e5757eaec4b922b92f97ec8d6d7e057cf06778247604fbc4e7c3f3/idna-3.19.tar.gz", hash = "sha256:5e0811a4383b21dc5838069f801c4fb62113b7447663d2530d2bd6e77b49bf15", size = 215237, upload-time = "2026-08-18T05:14:24.27Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, + { url = "https://files.pythonhosted.org/packages/57/b0/0e52c878c53f245edd3a11020f20979b3f490f245af532c7cae3027754b5/idna-3.19-py3-none-any.whl", hash = "sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4", size = 68550, upload-time = "2026-08-18T05:14:22.343Z" }, ] [[package]] @@ -1754,6 +1789,44 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/be/2f/5108cb3ee4ba6501748c4908b908e55f42a5b66245b4cfe0c99326e1ef6e/marshmallow-3.26.2-py3-none-any.whl", hash = "sha256:013fa8a3c4c276c24d26d84ce934dc964e2aa794345a0f8c7e5a7191482c8a73", size = 50964, upload-time = "2025-12-22T06:53:51.801Z" }, ] +[[package]] +name = "mcp" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "httpx2" }, + { name = "jsonschema" }, + { name = "mcp-types" }, + { name = "opentelemetry-api" }, + { name = "pydantic" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "python-multipart" }, + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "sse-starlette" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, + { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/31/ac54fb0fdd5b37de704486e288bba4fbbb463f24cfcfedbede407b854513/mcp-2.2.0.tar.gz", hash = "sha256:2dc37ecb1974becdcebdbf7561e7c15a07dbbf20ba21ba16c3593b3038b3afbd", size = 4084129, upload-time = "2026-09-07T16:06:23.439Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/ff/8e7eade68b8a28f7da0ed1085544341b51f9c935dbf6b95c76b7edfea6a0/mcp-2.2.0-py3-none-any.whl", hash = "sha256:bde982589473a060ae145e3406e9a5333fe538c97229ba841f5a7f92be004f81", size = 365656, upload-time = "2026-09-07T16:06:19.711Z" }, +] + +[[package]] +name = "mcp-types" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/91/762d7755d971aff8a28d75f7961656148edf27875c8026e6385aaab08ae7/mcp_types-2.2.0.tar.gz", hash = "sha256:d3ed53703ddd10d9c6399f29d322bb66f3f67ab41348ac8556ba23e07fedefad", size = 65892, upload-time = "2026-09-07T16:06:25.187Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/d7/6ffba5d8cd5dd9b8a19478875c50e04945314ba5074e84d749283f27f62d/mcp_types-2.2.0-py3-none-any.whl", hash = "sha256:ea476b73ee86709ab5abc9452385ed36cc05907e582355622e294595c9a04f13", size = 69106, upload-time = "2026-09-07T16:06:21.461Z" }, +] + [[package]] name = "mdurl" version = "0.1.2" @@ -2044,6 +2117,7 @@ dev = [ { name = "litellm" }, { name = "lxml" }, { name = "markdownify" }, + { name = "mcp" }, { name = "mypy" }, { name = "opencite" }, { name = "pre-commit" }, @@ -2076,6 +2150,7 @@ server = [ { name = "litellm" }, { name = "lxml" }, { name = "markdownify" }, + { name = "mcp" }, { name = "opencite" }, { name = "psycopg", extra = ["binary"] }, { name = "pydantic-settings" }, @@ -2116,6 +2191,8 @@ requires-dist = [ { name = "lxml", marker = "extra == 'server'", specifier = ">=6.0.0" }, { name = "markdownify", marker = "extra == 'dev'", specifier = ">=1.1.0" }, { name = "markdownify", marker = "extra == 'server'", specifier = ">=1.1.0" }, + { name = "mcp", marker = "extra == 'dev'", specifier = ">=2.2.0" }, + { name = "mcp", marker = "extra == 'server'", specifier = ">=2.2.0" }, { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.19.0" }, { name = "opencite", marker = "extra == 'dev'", specifier = ">=0.5.3" }, { name = "opencite", marker = "extra == 'server'", specifier = ">=0.5.3" }, @@ -2893,6 +2970,37 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" }, ] +[[package]] +name = "python-multipart" +version = "0.0.32" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" }, +] + +[[package]] +name = "pywin32" +version = "312" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/f5/10a6e845a00fc5e7afd0a988b744f403d4d57162a28d160a093c4d9322f0/pywin32-312-cp311-cp311-win32.whl", hash = "sha256:17948aeadbdb091f0ced6ef0841620794e68327b94ee415571c1203594b7215c", size = 6362659, upload-time = "2026-06-04T07:49:21.349Z" }, + { url = "https://files.pythonhosted.org/packages/35/c4/dcd2d62b5944b6d5db53413a5899016ccd57ffcb7278f3f81655d25d2027/pywin32-312-cp311-cp311-win_amd64.whl", hash = "sha256:d11417d84412f859b722fad0841b3614459ed0047f7542d8362e77884f6b6e8a", size = 6928825, upload-time = "2026-06-04T07:49:23.934Z" }, + { url = "https://files.pythonhosted.org/packages/b7/56/3cbb433fe4501cdba2eb9040f56a4e1a8243faa4186b25295564d1a7a79d/pywin32-312-cp311-cp311-win_arm64.whl", hash = "sha256:b2200a054ca6d6625c4842fc56a4976a4b47f96b73dbe5538c3f813a80359f47", size = 6721875, upload-time = "2026-06-04T07:49:26.416Z" }, + { url = "https://files.pythonhosted.org/packages/83/ff/32aa7d2ed0ab12b323aaa64f9b75e6ad4f8fd09f9ccfc28c79414d46838d/pywin32-312-cp312-cp312-win32.whl", hash = "sha256:dab4f65ac9c4e48400a2a0530c46c3c579cd5905ecd11b80692373915269208b", size = 6371877, upload-time = "2026-06-04T07:49:28.836Z" }, + { url = "https://files.pythonhosted.org/packages/03/d9/77040d3b43df3f3be32ea289433d660d2727f5ba327bc73be835127d9d60/pywin32-312-cp312-cp312-win_amd64.whl", hash = "sha256:b457f6d628a47e8a7346ce22acb7e1a46a4a78b52e1d17e1af56871bd19a93bc", size = 6914841, upload-time = "2026-06-04T07:49:31.85Z" }, + { url = "https://files.pythonhosted.org/packages/e3/cc/7b1ec671775756020a0ee7f4feeaf3c568f0ab86bd3900088cf986937a92/pywin32-312-cp312-cp312-win_arm64.whl", hash = "sha256:6017c58e12f6809fbb0555b75df144c2922a9ffd18e4b9b5afa863b6c1a9d950", size = 6727901, upload-time = "2026-06-04T07:49:34.244Z" }, + { url = "https://files.pythonhosted.org/packages/2d/41/12fbfd7f36ed2146d8bc9de96c2741296bf0d490b98508496cff322e274c/pywin32-312-cp313-cp313-win32.whl", hash = "sha256:7a27df850933d16a8eabfbaeb73d52b273e2da667f80d70b01a89d1f6828d02c", size = 6370184, upload-time = "2026-06-04T07:49:36.253Z" }, + { url = "https://files.pythonhosted.org/packages/ba/db/36a78e3403099d31d9746d13fdcde5accc43c1155f375a34d15983a479a7/pywin32-312-cp313-cp313-win_amd64.whl", hash = "sha256:c53e878d15a1c44788082bfe712a905433473aa38f86375b7cf8b45e3acbaaf9", size = 6914298, upload-time = "2026-06-04T07:49:38.876Z" }, + { url = "https://files.pythonhosted.org/packages/84/37/c1697194092b76de9ed47ca124323f02c57ffc8a45c06f88a3d5acaf01eb/pywin32-312-cp313-cp313-win_arm64.whl", hash = "sha256:59aba5d5940842075343a5ddc6b11f1cdf0d1567fe745290359dfbcc7c2eb831", size = 6727640, upload-time = "2026-06-04T07:49:41.083Z" }, + { url = "https://files.pythonhosted.org/packages/fc/2b/1f3cded5822fd49c02f40544cbb5f58c7cfd6b1694869fd476cb6170ee97/pywin32-312-cp314-cp314-win32.whl", hash = "sha256:a77a90fbb6881238d2ca9c6fd797b25817f3768fe78d214a90137ff055a75f5b", size = 6468928, upload-time = "2026-06-04T07:49:43.188Z" }, + { url = "https://files.pythonhosted.org/packages/21/82/3bf86d2e2808902013132e1ce905a7da0da53790f3836c64bf44d55e24f3/pywin32-312-cp314-cp314-win_amd64.whl", hash = "sha256:a4dd3a848290ef724347b19f301045831d8e802fa4464f491b98b1e0a081432e", size = 7024157, upload-time = "2026-06-04T07:49:45.34Z" }, + { url = "https://files.pythonhosted.org/packages/a4/0e/73f6d6800b4f27655abd9e9f6aaeaefcddb2b946e4674efa2bab184a7f7b/pywin32-312-cp314-cp314-win_arm64.whl", hash = "sha256:9fce94568364e0155e6dfb781ac5d95903be8baf28670632beab1b523f300daa", size = 6839598, upload-time = "2026-06-04T07:49:47.613Z" }, + { url = "https://files.pythonhosted.org/packages/eb/61/caa39686032d2ebdd04ff0ab5cbe163126c0066d98e00c9018646e42393b/pywin32-312-cp315-cp315-win32.whl", hash = "sha256:5c1fbe4a937a73ae9297384a3da38518cbc694c68ad8a809b2e19acd350f03ed", size = 6471159, upload-time = "2026-06-04T07:49:50.035Z" }, + { url = "https://files.pythonhosted.org/packages/0f/cd/7e1de64a4a6f69c04214169657ccab0d93a670ea50e35eb8f489d7378249/pywin32-312-cp315-cp315-win_amd64.whl", hash = "sha256:c2f03a0f73f804a13c2735b99392b0cd426bb4f2c4d0178e5ac966a0f21618d5", size = 7025293, upload-time = "2026-06-04T07:49:54.857Z" }, + { url = "https://files.pythonhosted.org/packages/23/ed/4532e9388e65fa16b46776ef47ad631a64eda1631884488af707666350ed/pywin32-312-cp315-cp315-win_arm64.whl", hash = "sha256:a8597d28f267b39074aef51fa593530082b39cbe5a074226096857b1fed2dfb9", size = 6840337, upload-time = "2026-06-04T07:49:57.531Z" }, +] + [[package]] name = "pyyaml" version = "6.0.3" @@ -3306,6 +3414,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/bf/e1/3ccb13c643399d22289c6a9786c1a91e3dcbb68bce4beb44926ac2c557bf/sqlalchemy-2.0.45-py3-none-any.whl", hash = "sha256:5225a288e4c8cc2308dbdd874edad6e7d0fd38eac1e9e5f23503425c8eee20d0", size = 1936672, upload-time = "2025-12-09T21:54:52.608Z" }, ] +[[package]] +name = "sse-starlette" +version = "3.4.11" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "starlette" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2b/54/6767bb789b2f2fed6e0f953df949cd39dc263a384c1b65a95232598621d6/sse_starlette-3.4.11.tar.gz", hash = "sha256:1bae716c02f3e6f294be41ff333220692dae7c3cbab077c900f159676719dade", size = 34972, upload-time = "2026-09-05T12:11:04.607Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/6a/2ba3ed4a69babf3afdddf7d8314a48d87562c0a442206bbc2a1b50d5efc0/sse_starlette-3.4.11-py3-none-any.whl", hash = "sha256:c7b2244bdff016fe7f64e10075e89a3e6bbf899649cc89b0fe884b5545042453", size = 17122, upload-time = "2026-09-05T12:11:03.195Z" }, +] + [[package]] name = "starlette" version = "0.50.0" @@ -3469,6 +3590,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d0/30/dc54f88dd4a2b5dc8a0279bdd7270e735851848b762aeb1c1184ed1f6b14/tqdm-4.67.1-py3-none-any.whl", hash = "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2", size = 78540, upload-time = "2024-11-24T20:12:19.698Z" }, ] +[[package]] +name = "truststore" +version = "0.10.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", size = 26169, upload-time = "2025-08-12T18:49:02.73Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" }, +] + [[package]] name = "typer" version = "0.21.1"