diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index b96a5eb2d..6883e54a3 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -33,8 +33,8 @@ jobs: - name: Install Python dependencies run: | python -m pip install --upgrade "pip<25.3" - pip install -r requirements/requirements-${{ matrix.os }}_py${{ matrix.python-version }}.txt pip install -r requirements/requirements-${{ matrix.os }}_py${{ matrix.python-version }}_extras.txt + pip install -e . --no-deps - name: Set SSL_CERT_FILE (Linux) if: matrix.os == 'ubuntu-latest' || matrix.os == 'macos-latest' @@ -59,7 +59,6 @@ jobs: MP_API_KEY: ${{ secrets[env.API_KEY_NAME] }} #MP_API_ENDPOINT: https://api-preview.materialsproject.org/ run: | - pip install -e . pytest -n auto -x --cov=mp_api --cov-report=xml - uses: codecov/codecov-action@v1 with: diff --git a/.gitignore b/.gitignore index bff73a585..6676ce5ec 100644 --- a/.gitignore +++ b/.gitignore @@ -121,3 +121,4 @@ _autosummary uv.lock JANAF_*_data.json +.gemini diff --git a/dev/generate_mcp_tools.py b/dev/generate_mcp_tools.py new file mode 100644 index 000000000..d40377159 --- /dev/null +++ b/dev/generate_mcp_tools.py @@ -0,0 +1,164 @@ +"""Define utilities for (mostly) auto-generating MCP tools. + +This file will autogenerate a (Fast)MCP set of tools with +type annotations. + +The resultant tools are perhaps too general for use in an MCP. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from mp_api.client import MPRester + +if TYPE_CHECKING: + from collections.abc import Callable + from pathlib import Path + + +def get_annotation_signature( + obj: Callable, tablen: int = 4 +) -> tuple[str | None, str | None]: + """Reconstruct the type annotations associated with a Callable. + + Returns the type annotations on input, and the output + kwargs as str if type annotations can be inferred. + """ + kwargs = None + out_kwargs = None + if (annos := obj.__annotations__) and (defaults := obj.__defaults__): + non_ret_type = [k for k in annos if k != "return"] + defaults = [f" = {val}" for val in defaults] + if len(defaults) < len(non_ret_type): + defaults = [""] * (len(non_ret_type) - len(defaults)) + defaults + kwargs = ",\n".join( + f"{' '*tablen}{k} : {v}{defaults[i]}" + for i, (k, v) in enumerate(annos.items()) + if k != "return" + ) + out_kwargs = ",\n".join( + f"{' '*2*tablen}{k} = {k}" for k in annos if k != "return" + ) + return kwargs, out_kwargs + + +def regenerate_tools( + client: MPRester | None = None, file_name: str | Path | None = None +) -> str: + """Utility to regenerate the informative tool names with annotations.""" + func_str = """# ruff: noqa +from __future__ import annotations + +from datetime import datetime +from typing import Literal + +from emmet.core.chemenv import ( + COORDINATION_GEOMETRIES, + COORDINATION_GEOMETRIES_IUCR, + COORDINATION_GEOMETRIES_IUPAC, + COORDINATION_GEOMETRIES_NAMES, +) +from emmet.core.electronic_structure import BSPathType, DOSProjectionType +from emmet.core.grain_boundary import GBTypeEnum +from emmet.core.mpid import MPID +from emmet.core.thermo import ThermoType +from emmet.core.summary import HasProps +from emmet.core.symmetry import CrystalSystem +from emmet.core.synthesis import SynthesisTypeEnum, OperationTypeEnum +from emmet.core.vasp.calc_types import CalcType +from emmet.core.xas import Edge, Type + +from pymatgen.analysis.magnetism.analyzer import Ordering +from pymatgen.core.periodic_table import Element +from pymatgen.core.structure import Structure +from pymatgen.electronic_structure.core import OrbitalType, Spin + +""" + + translate = { + "chemenv": "chemical_environment", + "dos": "density_of_states", + "eos": "equation_of_state", + "summary": "material", + "robocrys": "crystal_summary", + } + + mp_client = client or MPRester() + + def _get_rester_sub_name(name, route) -> str | None: + for y in [x for x in dir(route) if not x.startswith("_")]: + attr = getattr(route, y) + if ( + (hasattr(attr, "__name__") and attr.__name__ == name) + or (hasattr(attr, "__class__")) + and attr.__class__.__name__ == name + ): + return y + return None + + for x in mp_client._all_resters: + if not ( + sub_rest_route := _get_rester_sub_name(x.__name__, mp_client.materials) + ): + continue + + search_method = "search" + if "robocrys" in x.__name__.lower(): + search_method = "search_docs" + + informed_name = sub_rest_route + for k, v in translate.items(): + if k in informed_name: + informed_name = informed_name.replace(k, v) + + kwargs, out_kwargs = get_annotation_signature(getattr(x, search_method)) + if not kwargs: + # FastMCP raises a ValueError if types are not provided: + # `Functions with **kwargs are not supported as tools` + continue + func_str += ( + f"def get_{informed_name}_data(\n" + f" self,\n{kwargs}\n) -> list[dict]:\n" + f" return self.client.materials.{sub_rest_route}" + f".search(\n{out_kwargs}\n)\n\n" + ) + + helpers = [ + method + for method in dir(mp_client) + if any( + method.startswith(signature) + for signature in ( + "get", + "find", + ) + ) + ] + for func_name in helpers: + func = getattr(mp_client, func_name) + # MCP doesn't work with LRU cached functions? + if hasattr(func, "cache_info"): + continue + + kwargs, out_kwargs = get_annotation_signature(func) + if not kwargs: + continue + + informed_name = func_name.replace("find", "get") + for k, v in translate.items(): + if k in informed_name: + informed_name = informed_name.replace(k, v) + + func_str += ( + f"def {informed_name}(\n" + f" self,\n{kwargs}\n) -> list[dict]:\n" + f" return self.client.{func_name}(\n" + f"{out_kwargs}\n)\n\n" + ) + + if file_name: + with open(file_name, "w") as f: + f.write(func_str) + + return func_str diff --git a/dev/inspect_mcp.sh b/dev/inspect_mcp.sh new file mode 100644 index 000000000..4bb15a940 --- /dev/null +++ b/dev/inspect_mcp.sh @@ -0,0 +1,12 @@ +#!/bin/bash -l + +# Tool to run the MCP inspector: +# https://modelcontextprotocol.io/docs/tools/inspector + +server_path=$(python -c 'from importlib_resources import files ; print(str((files("mp_api.client") / ".."/ "..").resolve()))') + +fastmcp dev \ + --python 3.12 \ + --with-editable $server_path \ + --with-requirements "$server_path/requirements/requirements-ubuntu-latest_py3.12_extras.txt" \ + "$server_path/mp_api/mcp/server.py" diff --git a/mp_api/mcp/__init__.py b/mp_api/mcp/__init__.py new file mode 100644 index 000000000..07ee0f467 --- /dev/null +++ b/mp_api/mcp/__init__.py @@ -0,0 +1 @@ +"""Get default MCP for Materials Project.""" diff --git a/mp_api/mcp/_schemas.py b/mp_api/mcp/_schemas.py new file mode 100644 index 000000000..6bf7be8d5 --- /dev/null +++ b/mp_api/mcp/_schemas.py @@ -0,0 +1,314 @@ +"""Define auxiliary schemas used by some LLMs.""" +from __future__ import annotations + +from typing import Any + +from pydantic import BaseModel, Field, model_validator + +from mp_api.client.core.utils import validate_ids + + +class MaterialMetadata(BaseModel): + """Define metadata associated to a material. + + These fields are a subset of the `emmet.core.summary.SummaryDoc` + model fields, with a few extra fields to include + robocrystallographer autogenerated descriptions and similarity scores. + """ + + nsites: int | None = Field( + None, + description="The number of sites in the structure as found on the Materials Project.", + ) + + nelements: int | None = Field( + None, description="The number of unique elements in this structure." + ) + formula_pretty: str | None = Field( + None, description="The chemical formula of this material." + ) + formula_anonymous: str | None = Field( + None, + description="The chemical formula of this material's prototype, i.e., without sp.", + ) + chemsys: str | None = Field( + None, + description="A dash-delimited string separating the unique elements in this material.", + ) + volume: float | None = Field( + None, + description="The volume of the structure associated with this material in cubic Angstrom (ų).", + ) + density: float | None = Field( + None, + description="The volume of the structure associated with this material in grams per cubic centimeter (g/cm³).", + ) + density_atomic: float | None = Field( + None, + description="The volume per atom of the structure associated with this material in ų/atom.", + ) + + space_group_number: int | None = Field( + None, + description="The international space group number of this material (Reference: https://en.wikipedia.org/wiki/Space_group#Table_of_space_groups_in_3_dimensions).", + ) + + space_group_symbol: str | None = Field( + None, + description="The international space group symbol of this material (Reference: https://en.wikipedia.org/wiki/Space_group#Table_of_space_groups_in_3_dimensions).", + ) + + crystal_system: str | None = Field( + None, + description="The crystal system of this material (Reference: https://en.wikipedia.org/wiki/Crystal_system)", + ) + + point_group: str | None = Field(None, description="The point group of the lattice.") + + material_id: str | None = Field( + None, + description="The Materials Project ID of this material, generally of the form `mp-1`, `mp-2`, etc.", + ) + + deprecated: bool = Field( + True, + description=( + "If True, the Materials Project considers this material to be deprecated / untrustworthy. " + "If False, the data contained herein should be considered robust." + ), + ) + + formation_energy_per_atom: float | None = Field( + None, + description="The DFT-computed enthalpy of formation at zero kelvin (0K) with corrections, in eV/atom.", + ) + + energy_above_hull: float | None = Field( + None, + description=( + "The energy above the thermodynamic hull for this material, in eV/atom. " + "This quantity is non-negative. Zero indicates a stable material. " + "Small values indicate slightly unstable materials, which may stabilize at " + "higher temperatures. Large values indicate highly unstable materials." + ), + ) + + is_stable: bool | None = Field( + None, + description="Whether this material lies on the hull, i.e., its `energy_above_hull` is almost zero.", + ) + + equilibrium_reaction_energy_per_atom: float | None = Field( + None, + description="The reaction energy of a stable entry from the neighboring equilibrium stable materials in eV." + " Also known as the inverse distance to hull.", + ) + + band_gap: float | None = Field( + None, + description="The electronic band gap energy in eV.", + ) + + cbm: float | None = Field( + None, + description="The Conduction Band Minimum (CBM) in eV.", + ) + + vbm: float | None = Field( + None, + description="The Valence Band Maximum (VBM) in eV.", + ) + + efermi: float | None = Field( + None, + description="The Fermi energy (or Fermi level) in eV.", + ) + + is_gap_direct: bool | None = Field( + None, + description="Whether the band gap is direct.", + ) + + is_metal: bool | None = Field( + None, + description="Whether the material is a metal (`band_gap` = 0).", + ) + + is_magnetic: bool | None = Field( + None, + description="Whether the material is magnetic.", + ) + + ordering: str | None = Field( + None, + description=( + "Type of collinear magnetic ordering: " + "`NM` indicates non-magnetic, " + "`FM` indicates ferromagnetic, " + "`FiM` indicates ferrimagnetic, " + "and `AFM` indicates antiferromagnetic." + ), + ) + + total_magnetization: float | None = Field( + None, + description="Total magnetization in Bohr magneton, μB.", + ) + + total_magnetization_normalized_vol: float | None = Field( + None, + description="Total magnetization normalized by volume in μB/ų.", + ) + + total_magnetization_normalized_formula_units: float | None = Field( + None, + description="Total magnetization normalized by formula unit in μB/(formula unit).", + ) + + num_magnetic_sites: int | None = Field( + None, + description="The number of magnetic sites.", + ) + + num_unique_magnetic_sites: int | None = Field( + None, + description="The number of unique magnetic sites.", + ) + + bulk_modulus_voigt: float | None = Field( + None, description="Voigt average of the bulk modulus in gigapascal (GPa)." + ) + + bulk_modulus_reuss: float | None = Field( + None, description="Reuss average of the bulk modulus in GPa." + ) + + bulk_modulus_vrh: float | None = Field( + None, description="Voigt-Reuss-Hill average of the bulk modulus in GPa." + ) + + shear_modulus_voigt: float | None = Field( + None, description="Voigt average of the shear modulus in GPa." + ) + + shear_modulus_reuss: float | None = Field( + None, description="Reuss average of the shear modulus in GPa." + ) + + shear_modulus_vrh: float | None = Field( + None, description="Voigt-Reuss-Hill average of the shear modulus in GPa." + ) + + universal_anisotropy: float | None = Field( + None, description="Elastic anisotropy, dimensionless." + ) + + homogeneous_poisson: float | None = Field( + None, description="Poisson ratio, dimensionless." + ) + + e_total: float | None = Field( + None, + description="Total dielectric constant, dimensionless.", + ) + + e_ionic: float | None = Field( + None, + description="Ionic contribution to dielectric constant, dimensionless.", + ) + + e_electronic: float | None = Field( + None, + description="Electronic contribution to dielectric constant, dimensionless.", + ) + + n: float | None = Field( + None, + description="The optical refractive index, dimensionless.", + ) + + theoretical: bool = Field( + True, + description=( + "Whether the material has not been matched to a structure " + "in an experimental database. If this is `True`, then both " + "`linked_icsd_ids` and `linked_pf_ids` should be `None`." + ), + ) + + linked_icsd_ids: str | None = Field( + None, + description=( + "A comma-delimited list of Inorganic Crystal Structure Database " + "(ICSD) identifiers of materials which have been matched to this " + "material. Example: 'icsd-1, icsd-2, icsd-10'" + ), + ) + + linked_pf_ids: str | None = Field( + None, + description=( + "A comma-delimited list of Pauling File (pf) " + "identifiers of materials which have been matched to this " + "material. Example: 'pf-1, pf-2, pf-10'" + ), + ) + + structurally_similar_materials: str | None = Field( + None, + description=( + "A comma-delimited list of structurally similar materials " + "in the Materials Project, with additional information about their " + "chemistry and similarity ranking. Example: " + r"'mp-104: LiS (98.1% similar); mp-50505: NaP (94.3% similar)'" + ), + ) + + +class FetchResult(BaseModel): + """Schematize result of the `fetch` MCP tool. + + This schema is designed for compatibility with OpenAI's spec: + https://platform.openai.com/docs/mcp#fetch-tool + + However it should be compatible with other LLMs as well. + """ + + id: str = Field( + description="The Materials Project ID of this entry, of the form `mp-13`." + ) + title: str | None = Field(None, description="Typically the Materials Project ID.") + text: str | None = Field( + None, + description="The robocrystallographer autogenerated description of this material.", + ) + url: str | None = Field(None, description="A link to the Materials Project website") + metadata: MaterialMetadata | None = Field( + None, + description="Auxiliary materials data aggregated from the summary and similarity collections in the Materials Project.", + ) + + @model_validator(mode="before") + def set_url(cls, config: Any) -> Any: + """Set default Materials Project URL and title.""" + formatted_mpid = validate_ids([config.get("id")])[0] + if not config.get("title"): + config["title"] = formatted_mpid + + if not config.get("url"): + config["url"] = ( + "https://next-gen.materialsproject.org/materials/" f"{formatted_mpid}" + ) + return config + + +class SearchOutput(BaseModel): + """Schematize data for the MCP `search` tool. + + This schema is designed for compatibility with OpenAI's spec: + https://platform.openai.com/docs/mcp#search-tool + + But will also be compatible with most other LLMs. + """ + + results: list[FetchResult] = Field([], description="A list of results") diff --git a/mp_api/mcp/mp_mcp.py b/mp_api/mcp/mp_mcp.py new file mode 100644 index 000000000..36199bc2f --- /dev/null +++ b/mp_api/mcp/mp_mcp.py @@ -0,0 +1,26 @@ +"""Define custom MCP tools for the Materials Project API.""" +from __future__ import annotations + +from fastmcp import FastMCP + +from mp_api.mcp.tools import MPCoreMCP + +MCP_SERVER_INSTRUCTIONS = """ +This MCP server defines search and document retrieval capabilities +for data in the Materials Project. +Use the search tool to find relevant documents based on materials +keywords. +Then use the fetch tool to retrieve complete materials summary information. +""" + + +def get_core_mcp() -> FastMCP: + """Create an MCP compatible with OpenAI models.""" + mp_mcp = FastMCP( + "Materials_Project_MCP", + instructions=MCP_SERVER_INSTRUCTIONS, + ) + core_tools = MPCoreMCP() + for k in {"search", "fetch"}: + mp_mcp.tool(getattr(core_tools, k), name=k) + return mp_mcp diff --git a/mp_api/mcp/server.py b/mp_api/mcp/server.py new file mode 100644 index 000000000..735dd0f93 --- /dev/null +++ b/mp_api/mcp/server.py @@ -0,0 +1,75 @@ +"""Configure the Materials Project MCP server.""" +from __future__ import annotations + +from argparse import ArgumentParser +from typing import TYPE_CHECKING, get_args + +from fastmcp import FastMCP +from fastmcp.server.server import Transport + +from mp_api.client.core.exceptions import MPRestError +from mp_api.mcp.tools import MPCoreMCP + +if TYPE_CHECKING: + from collections.abc import Sequence + from typing import Any + +MCP_SERVER_INSTRUCTIONS = """ +This MCP server defines search and document retrieval capabilities +for data in the Materials Project. +Use the search tool to find relevant documents based on materials +keywords. +Then use the fetch tool to retrieve complete materials summary information. +""" + + +def get_core_mcp() -> FastMCP: + """Create an MCP compatible with OpenAI models.""" + mp_mcp = FastMCP( + "Materials_Project_MCP", + instructions=MCP_SERVER_INSTRUCTIONS, + ) + core_tools = MPCoreMCP() + for k in {"search", "fetch"}: + mp_mcp.tool(getattr(core_tools, k), name=k) + return mp_mcp + + +def parse_server_args(args: Sequence[str] | None = None) -> dict[str, Any]: + """Parse CLI arguments for server configuration.""" + server_config = {"transport", "host", "port"} + transport_vals = get_args(Transport) + + arg_parser = ArgumentParser() + arg_parser.add_argument( + "--transport", + type=str, + required=False, + ) + arg_parser.add_argument( + "--host", + type=str, + required=False, + ) + arg_parser.add_argument( + "--port", + type=int, + required=False, + ) + + parsed_args = arg_parser.parse_args(args=args) + kwargs = {} + for k in server_config: + if (v := getattr(parsed_args, k, None)) is not None: + if k == "transport" and v not in transport_vals: + raise MPRestError( + f"Invalid `transport={v}`, choose one of: {', '.join(transport_vals)}" + ) + kwargs[k] = v + return kwargs + + +mcp = get_core_mcp() + +if __name__ == "__main__": + mcp.run(**parse_server_args()) diff --git a/mp_api/mcp/tools.py b/mp_api/mcp/tools.py new file mode 100644 index 000000000..ccbc3f57e --- /dev/null +++ b/mp_api/mcp/tools.py @@ -0,0 +1,259 @@ +# ruff: noqa +from __future__ import annotations + +from datetime import datetime +from typing import Literal, Any + +import plotly.graph_objects as plotly_go + +from emmet.core.chemenv import ( + COORDINATION_GEOMETRIES, + COORDINATION_GEOMETRIES_IUCR, + COORDINATION_GEOMETRIES_IUPAC, + COORDINATION_GEOMETRIES_NAMES, +) +from emmet.core.electronic_structure import BSPathType, DOSProjectionType +from emmet.core.grain_boundary import GBTypeEnum +from emmet.core.mpid import MPID +from emmet.core.summary import HasProps +from emmet.core.symmetry import CrystalSystem +from emmet.core.synthesis import OperationTypeEnum, SynthesisTypeEnum +from emmet.core.thermo import ThermoType +from emmet.core.vasp.calc_types import CalcType +from emmet.core.xas import Edge, Type +from pymatgen.analysis.magnetism.analyzer import Ordering +from pymatgen.core.periodic_table import Element +from pymatgen.core.composition import Composition +from pymatgen.core.structure import Structure +from pymatgen.electronic_structure.core import OrbitalType, Spin +from pymatgen.entries.computed_entries import ComputedEntry + +from mp_api.client.core import MPRestError +from mp_api.mcp.utils import _NeedsMPClient +from mp_api.mcp._schemas import SearchOutput, FetchResult, MaterialMetadata + + +class MPCoreMCP(_NeedsMPClient): + """Define LLM-agnostic MCP for the Materials Project API. + + Because this MCP must support all common LLMs, it defines two methods, + `search` and `fetch`, which are compatible with OpenAI's spec. + """ + + @staticmethod + def _validate_chemical_system_formula(q: str) -> dict[str, str]: + """Check if a string query is a chemical formula or chemical system.""" + structured_query: dict[str, str] = {} + if "," not in q: + if "-" in q: + try: + structured_query["chemsys"] = "-".join( + Element(k).value for k in sorted(q.split("-")) + ) + except Exception: + pass + else: + try: + structured_query["formula"] = Composition(q).formula + except Exception: + pass + + return structured_query + + def search(self, query: str) -> SearchOutput: + """Define OpenAI compatible search. + + Search through the autogenerated robocrystallographer + descriptions of materials to return lists of likely + matching materials. + + Does not return the detailed `metadata` associated with a material. + To retrieve this, use the `fetch` method for a given result `id`. + + Args: + query (str) : A natural language query of either: + - comma-delimited keywords, example: "polyhedra, orthorhombic, superconductor" + - chemical formula, example: "TiO2" + - dash-delimited elements for more general chemical system, example: "Li-P-S" + To query by formula or chemical system, no commas should be present in the query. + + Returns: + SearchOutput, a dict of `results` each with structure + mp_api.mcp._schemas.SearchOutput + """ + robo_docs: list = [] + if summary_query := self._validate_chemical_system_formula(query): + # Check if query by chemical system / formula + material_ids = [ + doc["material_id"] + for doc in self.client.materials.summary.search( + **summary_query, fields=["material_id"] + ) + ] + robo_docs += self.client.materials.robocrys.search_docs( + material_ids=material_ids, fields=["description", "material_id"] + ) + else: + robo_docs += self.client.materials.robocrys.search(query) + + return SearchOutput( + results=[ + FetchResult(id=doc["material_id"], text=doc["description"]) + for doc in robo_docs + ] + ) + + def fetch(self, idx: str) -> FetchResult: + """Retrieve complete material information by Materials Project ID, formula, or chemical system. + + Args: + idx (str) : A Materials Project ID, chemical formula, or chemical system. + + If a Materials Project ID, this should be an integer prefixed by `mp-`. + Examples: "mp-149", "mp-13" + + If a chemical formula, only the most stable structure with that + reduced chemical formula is returned. + Examples: LiFePO4, Si + + If a chemical system, should be a dash-delimited list of elements. + The elements do not need to be sorted. + Examples: Ga-As, Si-P-O + + Returns: + FetchResult : Complete document with id, title, robocrys + autogenerated description, URL, and metadata derived from + the materials summary collection. + + If no data about the particular id is available, returns a + FetchResult with only the id field populated. + + Raises: + MPRestError: If no identifier is specified + """ + + if not isinstance(idx, str): + raise MPRestError( + f"Unknown {idx=}. Should be a Materials Project ID, " + "chemical formula, or chemical system." + ) + + # Assume this is a chemical formula or chemical system + if "mp-" not in idx: + summ_kwargs = {"fields": ["energy_above_hull", "material_id"]} + if "-" in idx: + summ_kwargs["chemsys"] = "-".join(sorted(idx.split("-"))) + else: + summ_kwargs["formula"] = idx + + if not (summ_docs := self.client.materials.summary.search(**summ_kwargs)): + return FetchResult(id=idx) + + idx = min(summ_docs, key=lambda doc: doc["energy_above_hull"])[ + "material_id" + ] + + robo_desc: str | None = None + if ( + len( + robo_docs := self.client.materials.robocrys.search_docs( + material_ids=[idx] + ) + ) + > 0 + ): + robo_desc = robo_docs[0]["description"] + + if not robo_desc: + return FetchResult(id=idx) + + metadata: dict[str, str] | None = None + if ( + len( + summary_docs := self.client.materials.summary.search(material_ids=[idx]) + ) + > 0 + ): + # Try to avoid more nested fields, just provide things with + # simple str or numeric type + summary_doc = summary_docs[0] + metadata = { + k: summary_doc[k] + for k in MaterialMetadata.model_fields + if summary_doc.get(k) is not None + } + for k in {"bulk", "shear"}: + if summary_doc.get(f"{k}_modulus"): + metadata.update( + { + f"{k}_modulus_{v}": summary_doc[f"{k}_modulus"].get(v) + for v in ("voigt", "reuss", "hill") + } + ) + + # Augment with experimental database id information + if summary_doc.get("database_IDs"): + metadata.update( + { + f"linked_{database}_ids": ", ".join(matched_ids) + for database, matched_ids in summary_doc["database_IDs"].items() + } + ) + + if (symm_meta := summary_doc.get("symmetry")) is not None: + metadata.update( + { + k: symm_meta.get(v) + for k, v in { + "space_group_number": "number", + "space_group_symbol": "symbol", + "crystal_system": "crystal_system", + "point_group": "point_group", + }.items() + } + ) + + if len(sim_docs := self.client.materials.similarity.find_similar(idx, top=10)): + if not isinstance(sim_docs[0], dict): + sim_docs = [doc.model_dump() for doc in sim_docs] + metadata.update( + structurally_similar_materials=( + ", ".join( + f"{doc['task_id']}: {doc['formula']} ({100. - doc['dissimilarity']:.1f}% similar)" + for doc in sim_docs + ) + ) + ) + + return FetchResult(id=idx, text=robo_desc, metadata=metadata) + + def get_phase_diagram_from_elements( + self, + elements: list[str], + thermo_type: Literal[ + *[x.value for x in ThermoType.__members__.values() if x.value != "UNKNOWN"] + ] + | str = "GGA_GGA+U_R2SCAN", + ) -> plotly_go.Figure: + """Find a thermodynamic phase diagram in the Materials Project by specified elements. + + Examples: + Given elements Na and Cl: + ``` + phase_diagram = MPMcpTools().get_phase_diagram_from_elements( + elements = ["Na","Cl"], + ) + ``` + + Given a chemical system, "K-P-O": + ``` + phase_diagrasm = MPMcpTools().get_phase_diagram_from_elements( + elements = "K-P-O".split("-"), + ) + ``` + + """ + pd = self.client.materials.thermo.get_phase_diagram_from_chemsys( + "-".join(elements), thermo_type + ) + return pd.get_plot() # has to be JSON serializable diff --git a/mp_api/mcp/utils.py b/mp_api/mcp/utils.py new file mode 100644 index 000000000..16bc60911 --- /dev/null +++ b/mp_api/mcp/utils.py @@ -0,0 +1,65 @@ +"""Define utilities for constructing MCP tools.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from mp_api.client import MPRester + +if TYPE_CHECKING: + from typing import Any + +_REQUIRED_CLIENT_KWARGS = { + "use_document_model": False, + "include_user_agent": True, +} + + +class _NeedsMPClient: + """Mix in to aid in API client interactions. + + Args: + client_kwargs : dict of str, Any, or None + Arguments to the API client. + Note that `use_document_model` will always be set to `False`, + and `include_user_agent` will always be set to True. + + Moreover, the user agent will start with `mp-mcp` rather than `mp-api`. + """ + + def __init__( + self, + client_kwargs: dict[str, Any] | None = None, + ): + self._client_kwargs = client_kwargs or {} + self.reset_client() + + def __enter__(self): + """Support for "with" context.""" + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + """Support for "with" context.""" + self.client.__exit__(exc_type, exc_val, exc_tb) + + def reset_client(self) -> None: + """Reset the API client.""" + self.client = MPRester( + **{ + **self._client_kwargs, + **_REQUIRED_CLIENT_KWARGS, + } + ) + self.client.session.headers["user-agent"] = self.client.session.headers[ + "user-agent" + ].replace("mp-api", "mp-mcp") + + def update_user_api_key(self, api_key: str) -> None: + """Change the API key used in the client. + + Call this method to set the user's API correctly. + Ask the user for their API key as plain text, + and input the result to this method. + """ + self._client_kwargs["api_key"] = api_key + self.reset_client() diff --git a/pyproject.toml b/pyproject.toml index 24920b084..93138b4b0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,7 +33,14 @@ dependencies = [ dynamic = ["version"] [project.optional-dependencies] -all = ["emmet-core[all]>=0.86.2", "custodian", "mpcontribs-client>=5.10"] +mcp = ["fastmcp"] +all = [ + "emmet-core[all]>=0.86.2", + "custodian", + "mpcontribs-client>=5.10", + "mp_api[mcp]", + "scipy<1.17.0", # pending fixes in matminer +] test = [ "pre-commit", "pytest", diff --git a/requirements/requirements-ubuntu-latest_py3.11.txt b/requirements/requirements-ubuntu-latest_py3.11.txt index 6d8b721ba..b7c060a94 100644 --- a/requirements/requirements-ubuntu-latest_py3.11.txt +++ b/requirements/requirements-ubuntu-latest_py3.11.txt @@ -10,13 +10,13 @@ bibtexparser==1.4.3 # via pymatgen blake3==1.0.8 # via emmet-core -boto3==1.42.4 +boto3==1.42.30 # via mp-api (pyproject.toml) -botocore==1.42.4 +botocore==1.42.30 # via # boto3 # s3transfer -certifi==2025.11.12 +certifi==2026.1.4 # via requests charset-normalizer==3.4.4 # via requests @@ -24,9 +24,9 @@ contourpy==1.3.3 # via matplotlib cycler==0.12.1 # via matplotlib -emmet-core==0.86.3rc0 +emmet-core==0.87.0.dev4 # via mp-api (pyproject.toml) -fonttools==4.61.0 +fonttools==4.61.1 # via matplotlib idna==3.11 # via requests @@ -34,13 +34,13 @@ jmespath==1.0.1 # via # boto3 # botocore -joblib==1.5.2 +joblib==1.5.3 # via pymatgen kiwisolver==1.4.9 # via matplotlib latexcodec==3.0.1 # via pybtex -matplotlib==3.10.7 +matplotlib==3.10.8 # via pymatgen monty==2025.3.3 # via @@ -51,11 +51,11 @@ mpmath==1.3.0 # via sympy msgpack==1.1.2 # via mp-api (pyproject.toml) -narwhals==2.13.0 +narwhals==2.15.0 # via plotly -networkx==3.6 +networkx==3.6.1 # via pymatgen -numpy==2.3.5 +numpy==2.4.1 # via # contourpy # matplotlib @@ -65,7 +65,7 @@ numpy==2.3.5 # pymatgen-io-validation # scipy # spglib -orjson==3.11.4 +orjson==3.11.5 # via # mp-api (pyproject.toml) # pymatgen @@ -77,9 +77,9 @@ palettable==3.3.3 # via pymatgen pandas==2.3.3 # via pymatgen -pillow==12.0.0 +pillow==12.1.0 # via matplotlib -plotly==6.5.0 +plotly==6.5.2 # via pymatgen pybtex==0.25.1 # via emmet-core @@ -101,7 +101,7 @@ pymatgen==2025.10.7 # pymatgen-io-validation pymatgen-io-validation==0.1.2 # via emmet-core -pyparsing==3.2.5 +pyparsing==3.3.1 # via # bibtexparser # matplotlib @@ -121,12 +121,10 @@ requests==2.32.5 # mp-api (pyproject.toml) # pymatgen # pymatgen-io-validation -ruamel-yaml==0.18.16 +ruamel-yaml==0.19.1 # via # monty # pymatgen -ruamel-yaml-clib==0.2.15 - # via ruamel-yaml s3transfer==0.16.0 # via boto3 scipy==1.16.3 @@ -135,7 +133,7 @@ six==1.17.0 # via python-dateutil smart-open==7.5.0 # via mp-api (pyproject.toml) -spglib==2.6.0 +spglib==2.7.0 # via pymatgen sympy==1.14.0 # via pymatgen @@ -156,11 +154,11 @@ typing-inspection==0.4.2 # via # pydantic # pydantic-settings -tzdata==2025.2 +tzdata==2025.3 # via pandas -uncertainties==3.2.3 +uncertainties==3.2.4 # via pymatgen -urllib3==2.6.0 +urllib3==2.6.3 # via # botocore # requests diff --git a/requirements/requirements-ubuntu-latest_py3.11_extras.txt b/requirements/requirements-ubuntu-latest_py3.11_extras.txt index ea20d7853..1c26d0ef4 100644 --- a/requirements/requirements-ubuntu-latest_py3.11_extras.txt +++ b/requirements/requirements-ubuntu-latest_py3.11_extras.txt @@ -8,27 +8,44 @@ alabaster==1.0.0 # via sphinx annotated-types==0.7.0 # via pydantic +anyio==4.12.1 + # via + # httpx + # mcp + # sse-starlette + # starlette arrow==1.4.0 # via isoduration -ase==3.26.0 +ase==3.27.0 # via pymatgen-analysis-diffusion asttokens==3.0.1 # via stack-data attrs==25.4.0 # via + # cyclopts # jsonschema # referencing +authlib==1.6.6 + # via fastmcp babel==2.17.0 # via sphinx +backports-tarfile==1.2.0 + # via jaraco-context +beartype==0.22.9 + # via + # py-key-value-aio + # py-key-value-shared bibtexparser==1.4.3 # via pymatgen blake3==1.0.8 # via emmet-core boltons==25.0.0 # via mpcontribs-client -boto3==1.42.4 - # via mp-api (pyproject.toml) -botocore==1.42.4 +boto3==1.42.30 + # via + # mp-api + # mp-api (pyproject.toml) +botocore==1.42.30 # via # boto3 # s3transfer @@ -36,39 +53,76 @@ bravado==12.0.1 # via mpcontribs-client bravado-core==6.1.1 # via bravado -cachetools==6.2.2 - # via mpcontribs-client -certifi==2025.11.12 - # via requests +cachetools==6.2.4 + # via + # mpcontribs-client + # py-key-value-aio +certifi==2026.1.4 + # via + # httpcore + # httpx + # requests +cffi==2.0.0 + # via cryptography cfgv==3.5.0 # via pre-commit charset-normalizer==3.4.4 # via requests +click==8.3.1 + # via + # typer + # uvicorn +cloudpickle==3.1.2 + # via pydocket contourpy==1.3.3 # via matplotlib -coverage[toml]==7.12.0 +coverage[toml]==7.13.1 # via pytest-cov -custodian==2025.10.11 +cryptography==46.0.3 + # via + # authlib + # pyjwt + # secretstorage +custodian==2025.12.14 # via mp-api (pyproject.toml) cycler==0.12.1 # via matplotlib +cyclopts==4.5.0 + # via fastmcp decorator==5.2.1 # via ipython +diskcache==5.6.3 + # via py-key-value-aio distlib==0.4.0 # via virtualenv dnspython==2.8.0 # via + # email-validator # pyisemail # pymongo -docutils==0.22.3 - # via sphinx -emmet-core[all]==0.86.3rc0 - # via mp-api (pyproject.toml) +docstring-parser==0.17.0 + # via cyclopts +docutils==0.22.4 + # via + # rich-rst + # sphinx +email-validator==2.3.0 + # via pydantic +emmet-core[all]==0.87.0.dev4 + # via + # mp-api + # mp-api (pyproject.toml) +exceptiongroup==1.3.1 + # via fastmcp execnet==2.1.2 # via pytest-xdist executing==2.2.1 # via stack-data -filelock==3.20.0 +fakeredis[lua]==2.33.0 + # via pydocket +fastmcp==2.14.3 + # via mp-api (pyproject.toml) +filelock==3.20.3 # via virtualenv filetype==1.2.0 # via mpcontribs-client @@ -80,41 +134,70 @@ flexcache==0.3 # via pint flexparser==0.4 # via pint -fonttools==4.61.0 +fonttools==4.61.1 # via matplotlib fqdn==1.5.1 # via jsonschema -identify==2.6.15 +h11==0.16.0 + # via + # httpcore + # uvicorn +httpcore==1.0.9 + # via httpx +httpx==0.28.1 + # via + # fastmcp + # mcp +httpx-sse==0.4.3 + # via mcp +identify==2.6.16 # via pre-commit idna==3.11 # via + # anyio + # email-validator + # httpx # jsonschema # requests imageio==2.37.2 # via scikit-image imagesize==1.4.1 # via sphinx +importlib-metadata==8.7.1 + # via + # keyring + # opentelemetry-api importlib-resources==6.5.2 # via swagger-spec-validator inflect==7.5.0 # via robocrys iniconfig==2.3.0 # via pytest -ipython==9.8.0 +ipython==9.9.0 # via mpcontribs-client ipython-pygments-lexers==1.1.1 # via ipython isoduration==20.11.0 # via jsonschema +jaraco-classes==3.4.0 + # via keyring +jaraco-context==6.1.0 + # via keyring +jaraco-functools==4.4.0 + # via keyring jedi==0.19.2 # via ipython +jeepney==0.9.0 + # via + # keyring + # secretstorage jinja2==3.1.6 # via sphinx jmespath==1.0.1 # via # boto3 # botocore -joblib==1.5.2 +joblib==1.5.3 # via # pymatgen # pymatgen-analysis-diffusion @@ -125,12 +208,17 @@ jsonpointer==3.0.0 # via jsonschema jsonref==1.1.0 # via bravado-core -jsonschema[format-nongpl]==4.25.1 +jsonschema[format-nongpl]==4.26.0 # via # bravado-core + # mcp # swagger-spec-validator +jsonschema-path==0.3.4 + # via fastmcp jsonschema-specifications==2025.9.1 # via jsonschema +keyring==25.7.0 + # via py-key-value-aio kiwisolver==1.4.9 # via matplotlib lark==1.3.1 @@ -139,15 +227,19 @@ latexcodec==3.0.1 # via pybtex lazy-loader==0.4 # via scikit-image -librt==0.7.0 +librt==0.7.8 # via mypy +lupa==2.6 + # via fakeredis +markdown-it-py==4.0.0 + # via rich markupsafe==3.0.3 # via jinja2 matminer==0.9.3 # via # emmet-core # robocrys -matplotlib==3.10.7 +matplotlib==3.10.8 # via # ase # pymatgen @@ -156,6 +248,10 @@ matplotlib-inline==0.2.1 # via ipython mccabe==0.7.0 # via flake8 +mcp==1.25.0 + # via fastmcp +mdurl==0.1.2 + # via markdown-it-py monotonic==1.6 # via bravado monty==2025.3.3 @@ -163,11 +259,17 @@ monty==2025.3.3 # custodian # emmet-core # matminer + # mp-api # mp-api (pyproject.toml) # pymatgen # robocrys more-itertools==10.8.0 - # via inflect + # via + # inflect + # jaraco-classes + # jaraco-functools +mp-api[mcp]==0.45.15 + # via mp-api (pyproject.toml) mp-pyrho==0.5.1 # via pymatgen-analysis-defects mpcontribs-client==5.10.4 @@ -178,23 +280,24 @@ msgpack==1.1.2 # via # bravado # bravado-core + # mp-api # mp-api (pyproject.toml) -mypy==1.19.0 +mypy==1.19.1 # via mp-api (pyproject.toml) mypy-extensions==1.1.0 # via # mp-api (pyproject.toml) # mypy -narwhals==2.13.0 +narwhals==2.15.0 # via plotly -networkx==3.6 +networkx==3.6.1 # via # pymatgen # robocrys # scikit-image -nodeenv==1.9.1 +nodeenv==1.10.0 # via pre-commit -numpy==2.3.5 +numpy==2.4.1 # via # ase # contourpy @@ -217,14 +320,35 @@ numpy==2.3.5 # shapely # spglib # tifffile -orjson==3.11.4 - # via +openapi-pydantic==0.5.1 + # via fastmcp +opentelemetry-api==1.39.1 + # via + # opentelemetry-exporter-prometheus + # opentelemetry-instrumentation + # opentelemetry-sdk + # opentelemetry-semantic-conventions + # pydocket +opentelemetry-exporter-prometheus==0.60b1 + # via pydocket +opentelemetry-instrumentation==0.60b1 + # via pydocket +opentelemetry-sdk==1.39.1 + # via opentelemetry-exporter-prometheus +opentelemetry-semantic-conventions==0.60b1 + # via + # opentelemetry-instrumentation + # opentelemetry-sdk +orjson==3.11.5 + # via + # mp-api # mp-api (pyproject.toml) # pymatgen packaging==25.0 # via # lazy-loader # matplotlib + # opentelemetry-instrumentation # plotly # pytest # scikit-image @@ -239,11 +363,15 @@ pandas==2.3.3 # seaborn parso==0.8.5 # via jedi -pathspec==0.12.1 +pathable==0.4.4 + # via jsonschema-path +pathspec==1.0.3 # via mypy +pathvalidate==3.3.1 + # via py-key-value-aio pexpect==4.9.0 # via ipython -pillow==12.0.0 +pillow==12.1.0 # via # imageio # matplotlib @@ -252,9 +380,10 @@ pint==0.25.2 # via mpcontribs-client platformdirs==4.5.1 # via + # fastmcp # pint # virtualenv -plotly==6.5.0 +plotly==6.5.2 # via # mpcontribs-client # pymatgen @@ -262,11 +391,15 @@ pluggy==1.6.0 # via # pytest # pytest-cov -pre-commit==4.5.0 +pre-commit==4.5.1 # via mp-api (pyproject.toml) +prometheus-client==0.24.1 + # via + # opentelemetry-exporter-prometheus + # pydocket prompt-toolkit==3.0.52 # via ipython -psutil==7.1.3 +psutil==7.2.1 # via custodian ptyprocess==0.7.0 # via pexpect @@ -274,6 +407,12 @@ pubchempy==1.0.5 # via robocrys pure-eval==0.2.3 # via stack-data +py-key-value-aio[disk,keyring,memory,redis]==0.3.0 + # via + # fastmcp + # pydocket +py-key-value-shared==0.3.0 + # via py-key-value-aio pyarrow==22.0.0 # via emmet-core pybtex==0.25.1 @@ -284,9 +423,14 @@ pycodestyle==2.14.0 # via # flake8 # mp-api (pyproject.toml) -pydantic==2.12.5 +pycparser==2.23 + # via cffi +pydantic[email]==2.12.5 # via # emmet-core + # fastmcp + # mcp + # openapi-pydantic # pydantic-settings # pymatgen-io-validation pydantic-core==2.41.5 @@ -294,7 +438,10 @@ pydantic-core==2.41.5 pydantic-settings==2.12.0 # via # emmet-core + # mcp # pymatgen-io-validation +pydocket==0.16.6 + # via fastmcp pyflakes==3.4.0 # via flake8 pygments==2.19.2 @@ -302,13 +449,17 @@ pygments==2.19.2 # ipython # ipython-pygments-lexers # pytest + # rich # sphinx pyisemail==2.0.1 # via mpcontribs-client +pyjwt[crypto]==2.10.1 + # via mcp pymatgen==2025.10.7 # via # emmet-core # matminer + # mp-api # mp-api (pyproject.toml) # mp-pyrho # mpcontribs-client @@ -325,15 +476,17 @@ pymatgen-analysis-diffusion==2025.11.15 # via emmet-core pymatgen-io-validation==0.1.2 # via emmet-core -pymongo==4.15.5 +pymongo==4.16.0 # via # matminer # mpcontribs-client -pyparsing==3.2.5 +pyparsing==3.3.1 # via # bibtexparser # matplotlib -pytest==9.0.1 +pyperclip==1.11.0 + # via fastmcp +pytest==9.0.2 # via # mp-api (pyproject.toml) # pytest-asyncio @@ -357,7 +510,13 @@ python-dateutil==2.9.0.post0 # matplotlib # pandas python-dotenv==1.2.1 - # via pydantic-settings + # via + # fastmcp + # pydantic-settings +python-json-logger==4.0.0 + # via pydocket +python-multipart==0.0.21 + # via mcp pytz==2025.2 # via # bravado-core @@ -366,18 +525,27 @@ pyyaml==6.0.3 # via # bravado # bravado-core + # jsonschema-path # pre-commit # pybtex # swagger-spec-validator -referencing==0.37.0 +redis==7.1.0 + # via + # fakeredis + # py-key-value-aio + # pydocket +referencing==0.36.2 # via # jsonschema + # jsonschema-path # jsonschema-specifications requests==2.32.5 # via # bravado # bravado-core + # jsonschema-path # matminer + # mp-api # mp-api (pyproject.toml) # pymatgen # pymatgen-io-validation @@ -391,26 +559,33 @@ rfc3986-validator==0.1.1 # via jsonschema rfc3987-syntax==1.1.0 # via jsonschema +rich==14.2.0 + # via + # cyclopts + # fastmcp + # pydocket + # rich-rst + # typer +rich-rst==1.3.2 + # via cyclopts robocrys==0.2.12 # via emmet-core -roman-numerals==3.1.0 +roman-numerals==4.1.0 # via sphinx rpds-py==0.30.0 # via # jsonschema # referencing -ruamel-yaml==0.18.16 +ruamel-yaml==0.19.1 # via # custodian # monty # pymatgen -ruamel-yaml-clib==0.2.15 - # via ruamel-yaml s3transfer==0.16.0 # via boto3 -scikit-image==0.25.2 +scikit-image==0.26.0 # via pymatgen-analysis-defects -scikit-learn==1.7.2 +scikit-learn==1.8.0 # via matminer scipy==1.16.3 # via @@ -421,12 +596,16 @@ scipy==1.16.3 # scikit-learn seaborn==0.13.2 # via pymatgen-analysis-diffusion +secretstorage==3.5.0 + # via keyring seekpath==2.1.0 # via emmet-core semantic-version==2.10.0 # via mpcontribs-client shapely==2.1.2 # via pymatgen-analysis-alloys +shellingham==1.5.4 + # via typer simplejson==3.20.2 # via # bravado @@ -439,10 +618,14 @@ six==1.17.0 # python-dateutil # rfc3339-validator smart-open==7.5.0 - # via mp-api (pyproject.toml) + # via + # mp-api + # mp-api (pyproject.toml) snowballstemmer==3.0.1 # via sphinx -spglib==2.6.0 +sortedcontainers==2.4.0 + # via fakeredis +spglib==2.7.0 # via # pymatgen # robocrys @@ -461,8 +644,14 @@ sphinxcontrib-qthelp==2.0.0 # via sphinx sphinxcontrib-serializinghtml==2.0.0 # via sphinx +sse-starlette==3.1.2 + # via mcp stack-data==0.6.3 # via ipython +starlette==0.51.0 + # via + # mcp + # sse-starlette swagger-spec-validator==3.0.4 # via # bravado-core @@ -475,7 +664,7 @@ tabulate==0.9.0 # via pymatgen threadpoolctl==3.6.0 # via scikit-learn -tifffile==2025.10.16 +tifffile==2026.1.14 # via scikit-image tqdm==4.67.1 # via @@ -488,53 +677,77 @@ traitlets==5.14.3 # matplotlib-inline typeguard==4.4.4 # via inflect -types-requests==2.32.4.20250913 +typer==0.21.1 + # via pydocket +types-requests==2.32.4.20260107 # via mp-api (pyproject.toml) -types-setuptools==80.9.0.20250822 +types-setuptools==80.9.0.20251223 # via mp-api (pyproject.toml) typing-extensions==4.15.0 # via + # anyio # blake3 # bravado # emmet-core + # exceptiongroup # flexcache # flexparser # ipython + # mcp + # mp-api # mp-api (pyproject.toml) # mypy + # opentelemetry-api + # opentelemetry-sdk + # opentelemetry-semantic-conventions # pint + # py-key-value-shared # pydantic # pydantic-core + # pydocket # pytest-asyncio # referencing # spglib + # starlette # swagger-spec-validator # typeguard + # typer # typing-inspection typing-inspection==0.4.2 # via + # mcp # pydantic # pydantic-settings -tzdata==2025.2 +tzdata==2025.3 # via # arrow # pandas ujson==5.11.0 # via mpcontribs-client -uncertainties==3.2.3 +uncertainties==3.2.4 # via pymatgen uri-template==1.3.0 # via jsonschema -urllib3==2.6.0 +urllib3==2.6.3 # via # botocore # requests # types-requests -virtualenv==20.35.4 +uvicorn==0.40.0 + # via + # fastmcp + # mcp +virtualenv==20.36.1 # via pre-commit wcwidth==0.2.14 # via prompt-toolkit webcolors==25.10.0 # via jsonschema -wrapt==2.0.1 - # via smart-open +websockets==16.0 + # via fastmcp +wrapt==1.17.3 + # via + # opentelemetry-instrumentation + # smart-open +zipp==3.23.0 + # via importlib-metadata diff --git a/requirements/requirements-ubuntu-latest_py3.12.txt b/requirements/requirements-ubuntu-latest_py3.12.txt index 98658bd8e..122850566 100644 --- a/requirements/requirements-ubuntu-latest_py3.12.txt +++ b/requirements/requirements-ubuntu-latest_py3.12.txt @@ -10,13 +10,13 @@ bibtexparser==1.4.3 # via pymatgen blake3==1.0.8 # via emmet-core -boto3==1.42.4 +boto3==1.42.30 # via mp-api (pyproject.toml) -botocore==1.42.4 +botocore==1.42.30 # via # boto3 # s3transfer -certifi==2025.11.12 +certifi==2026.1.4 # via requests charset-normalizer==3.4.4 # via requests @@ -24,9 +24,9 @@ contourpy==1.3.3 # via matplotlib cycler==0.12.1 # via matplotlib -emmet-core==0.86.3rc0 +emmet-core==0.87.0.dev4 # via mp-api (pyproject.toml) -fonttools==4.61.0 +fonttools==4.61.1 # via matplotlib idna==3.11 # via requests @@ -34,13 +34,13 @@ jmespath==1.0.1 # via # boto3 # botocore -joblib==1.5.2 +joblib==1.5.3 # via pymatgen kiwisolver==1.4.9 # via matplotlib latexcodec==3.0.1 # via pybtex -matplotlib==3.10.7 +matplotlib==3.10.8 # via pymatgen monty==2025.3.3 # via @@ -51,11 +51,11 @@ mpmath==1.3.0 # via sympy msgpack==1.1.2 # via mp-api (pyproject.toml) -narwhals==2.13.0 +narwhals==2.15.0 # via plotly -networkx==3.6 +networkx==3.6.1 # via pymatgen -numpy==2.3.5 +numpy==2.4.1 # via # contourpy # matplotlib @@ -65,7 +65,7 @@ numpy==2.3.5 # pymatgen-io-validation # scipy # spglib -orjson==3.11.4 +orjson==3.11.5 # via # mp-api (pyproject.toml) # pymatgen @@ -77,9 +77,9 @@ palettable==3.3.3 # via pymatgen pandas==2.3.3 # via pymatgen -pillow==12.0.0 +pillow==12.1.0 # via matplotlib -plotly==6.5.0 +plotly==6.5.2 # via pymatgen pybtex==0.25.1 # via emmet-core @@ -101,7 +101,7 @@ pymatgen==2025.10.7 # pymatgen-io-validation pymatgen-io-validation==0.1.2 # via emmet-core -pyparsing==3.2.5 +pyparsing==3.3.1 # via # bibtexparser # matplotlib @@ -121,12 +121,10 @@ requests==2.32.5 # mp-api (pyproject.toml) # pymatgen # pymatgen-io-validation -ruamel-yaml==0.18.16 +ruamel-yaml==0.19.1 # via # monty # pymatgen -ruamel-yaml-clib==0.2.15 - # via ruamel-yaml s3transfer==0.16.0 # via boto3 scipy==1.16.3 @@ -135,7 +133,7 @@ six==1.17.0 # via python-dateutil smart-open==7.5.0 # via mp-api (pyproject.toml) -spglib==2.6.0 +spglib==2.7.0 # via pymatgen sympy==1.14.0 # via pymatgen @@ -155,11 +153,11 @@ typing-inspection==0.4.2 # via # pydantic # pydantic-settings -tzdata==2025.2 +tzdata==2025.3 # via pandas -uncertainties==3.2.3 +uncertainties==3.2.4 # via pymatgen -urllib3==2.6.0 +urllib3==2.6.3 # via # botocore # requests diff --git a/requirements/requirements-ubuntu-latest_py3.12_extras.txt b/requirements/requirements-ubuntu-latest_py3.12_extras.txt index bcdeb1370..5a7a87730 100644 --- a/requirements/requirements-ubuntu-latest_py3.12_extras.txt +++ b/requirements/requirements-ubuntu-latest_py3.12_extras.txt @@ -8,27 +8,42 @@ alabaster==1.0.0 # via sphinx annotated-types==0.7.0 # via pydantic +anyio==4.12.1 + # via + # httpx + # mcp + # sse-starlette + # starlette arrow==1.4.0 # via isoduration -ase==3.26.0 +ase==3.27.0 # via pymatgen-analysis-diffusion asttokens==3.0.1 # via stack-data attrs==25.4.0 # via + # cyclopts # jsonschema # referencing +authlib==1.6.6 + # via fastmcp babel==2.17.0 # via sphinx +beartype==0.22.9 + # via + # py-key-value-aio + # py-key-value-shared bibtexparser==1.4.3 # via pymatgen blake3==1.0.8 # via emmet-core boltons==25.0.0 # via mpcontribs-client -boto3==1.42.4 - # via mp-api (pyproject.toml) -botocore==1.42.4 +boto3==1.42.30 + # via + # mp-api + # mp-api (pyproject.toml) +botocore==1.42.30 # via # boto3 # s3transfer @@ -36,39 +51,76 @@ bravado==12.0.1 # via mpcontribs-client bravado-core==6.1.1 # via bravado -cachetools==6.2.2 - # via mpcontribs-client -certifi==2025.11.12 - # via requests +cachetools==6.2.4 + # via + # mpcontribs-client + # py-key-value-aio +certifi==2026.1.4 + # via + # httpcore + # httpx + # requests +cffi==2.0.0 + # via cryptography cfgv==3.5.0 # via pre-commit charset-normalizer==3.4.4 # via requests +click==8.3.1 + # via + # typer + # uvicorn +cloudpickle==3.1.2 + # via pydocket contourpy==1.3.3 # via matplotlib -coverage[toml]==7.12.0 +coverage[toml]==7.13.1 # via pytest-cov -custodian==2025.10.11 +cryptography==46.0.3 + # via + # authlib + # pyjwt + # secretstorage +custodian==2025.12.14 # via mp-api (pyproject.toml) cycler==0.12.1 # via matplotlib +cyclopts==4.5.0 + # via fastmcp decorator==5.2.1 # via ipython +diskcache==5.6.3 + # via py-key-value-aio distlib==0.4.0 # via virtualenv dnspython==2.8.0 # via + # email-validator # pyisemail # pymongo -docutils==0.22.3 - # via sphinx -emmet-core[all]==0.86.3rc0 - # via mp-api (pyproject.toml) +docstring-parser==0.17.0 + # via cyclopts +docutils==0.22.4 + # via + # rich-rst + # sphinx +email-validator==2.3.0 + # via pydantic +emmet-core[all]==0.87.0.dev4 + # via + # mp-api + # mp-api (pyproject.toml) +exceptiongroup==1.3.1 + # via fastmcp execnet==2.1.2 # via pytest-xdist executing==2.2.1 # via stack-data -filelock==3.20.0 +fakeredis[lua]==2.33.0 + # via pydocket +fastmcp==2.14.3 + # via mp-api (pyproject.toml) +filelock==3.20.3 # via virtualenv filetype==1.2.0 # via mpcontribs-client @@ -80,41 +132,68 @@ flexcache==0.3 # via pint flexparser==0.4 # via pint -fonttools==4.61.0 +fonttools==4.61.1 # via matplotlib fqdn==1.5.1 # via jsonschema -identify==2.6.15 +h11==0.16.0 + # via + # httpcore + # uvicorn +httpcore==1.0.9 + # via httpx +httpx==0.28.1 + # via + # fastmcp + # mcp +httpx-sse==0.4.3 + # via mcp +identify==2.6.16 # via pre-commit idna==3.11 # via + # anyio + # email-validator + # httpx # jsonschema # requests imageio==2.37.2 # via scikit-image imagesize==1.4.1 # via sphinx +importlib-metadata==8.7.1 + # via opentelemetry-api importlib-resources==6.5.2 # via swagger-spec-validator inflect==7.5.0 # via robocrys iniconfig==2.3.0 # via pytest -ipython==9.8.0 +ipython==9.9.0 # via mpcontribs-client ipython-pygments-lexers==1.1.1 # via ipython isoduration==20.11.0 # via jsonschema +jaraco-classes==3.4.0 + # via keyring +jaraco-context==6.1.0 + # via keyring +jaraco-functools==4.4.0 + # via keyring jedi==0.19.2 # via ipython +jeepney==0.9.0 + # via + # keyring + # secretstorage jinja2==3.1.6 # via sphinx jmespath==1.0.1 # via # boto3 # botocore -joblib==1.5.2 +joblib==1.5.3 # via # pymatgen # pymatgen-analysis-diffusion @@ -125,12 +204,17 @@ jsonpointer==3.0.0 # via jsonschema jsonref==1.1.0 # via bravado-core -jsonschema[format-nongpl]==4.25.1 +jsonschema[format-nongpl]==4.26.0 # via # bravado-core + # mcp # swagger-spec-validator +jsonschema-path==0.3.4 + # via fastmcp jsonschema-specifications==2025.9.1 # via jsonschema +keyring==25.7.0 + # via py-key-value-aio kiwisolver==1.4.9 # via matplotlib lark==1.3.1 @@ -139,15 +223,19 @@ latexcodec==3.0.1 # via pybtex lazy-loader==0.4 # via scikit-image -librt==0.7.0 +librt==0.7.8 # via mypy +lupa==2.6 + # via fakeredis +markdown-it-py==4.0.0 + # via rich markupsafe==3.0.3 # via jinja2 matminer==0.9.3 # via # emmet-core # robocrys -matplotlib==3.10.7 +matplotlib==3.10.8 # via # ase # pymatgen @@ -156,6 +244,10 @@ matplotlib-inline==0.2.1 # via ipython mccabe==0.7.0 # via flake8 +mcp==1.25.0 + # via fastmcp +mdurl==0.1.2 + # via markdown-it-py monotonic==1.6 # via bravado monty==2025.3.3 @@ -163,11 +255,17 @@ monty==2025.3.3 # custodian # emmet-core # matminer + # mp-api # mp-api (pyproject.toml) # pymatgen # robocrys more-itertools==10.8.0 - # via inflect + # via + # inflect + # jaraco-classes + # jaraco-functools +mp-api[mcp]==0.45.15 + # via mp-api (pyproject.toml) mp-pyrho==0.5.1 # via pymatgen-analysis-defects mpcontribs-client==5.10.4 @@ -178,23 +276,24 @@ msgpack==1.1.2 # via # bravado # bravado-core + # mp-api # mp-api (pyproject.toml) -mypy==1.19.0 +mypy==1.19.1 # via mp-api (pyproject.toml) mypy-extensions==1.1.0 # via # mp-api (pyproject.toml) # mypy -narwhals==2.13.0 +narwhals==2.15.0 # via plotly -networkx==3.6 +networkx==3.6.1 # via # pymatgen # robocrys # scikit-image -nodeenv==1.9.1 +nodeenv==1.10.0 # via pre-commit -numpy==2.3.5 +numpy==2.4.1 # via # ase # contourpy @@ -217,14 +316,35 @@ numpy==2.3.5 # shapely # spglib # tifffile -orjson==3.11.4 - # via +openapi-pydantic==0.5.1 + # via fastmcp +opentelemetry-api==1.39.1 + # via + # opentelemetry-exporter-prometheus + # opentelemetry-instrumentation + # opentelemetry-sdk + # opentelemetry-semantic-conventions + # pydocket +opentelemetry-exporter-prometheus==0.60b1 + # via pydocket +opentelemetry-instrumentation==0.60b1 + # via pydocket +opentelemetry-sdk==1.39.1 + # via opentelemetry-exporter-prometheus +opentelemetry-semantic-conventions==0.60b1 + # via + # opentelemetry-instrumentation + # opentelemetry-sdk +orjson==3.11.5 + # via + # mp-api # mp-api (pyproject.toml) # pymatgen packaging==25.0 # via # lazy-loader # matplotlib + # opentelemetry-instrumentation # plotly # pytest # scikit-image @@ -239,11 +359,15 @@ pandas==2.3.3 # seaborn parso==0.8.5 # via jedi -pathspec==0.12.1 +pathable==0.4.4 + # via jsonschema-path +pathspec==1.0.3 # via mypy +pathvalidate==3.3.1 + # via py-key-value-aio pexpect==4.9.0 # via ipython -pillow==12.0.0 +pillow==12.1.0 # via # imageio # matplotlib @@ -252,9 +376,10 @@ pint==0.25.2 # via mpcontribs-client platformdirs==4.5.1 # via + # fastmcp # pint # virtualenv -plotly==6.5.0 +plotly==6.5.2 # via # mpcontribs-client # pymatgen @@ -262,11 +387,15 @@ pluggy==1.6.0 # via # pytest # pytest-cov -pre-commit==4.5.0 +pre-commit==4.5.1 # via mp-api (pyproject.toml) +prometheus-client==0.24.1 + # via + # opentelemetry-exporter-prometheus + # pydocket prompt-toolkit==3.0.52 # via ipython -psutil==7.1.3 +psutil==7.2.1 # via custodian ptyprocess==0.7.0 # via pexpect @@ -274,6 +403,12 @@ pubchempy==1.0.5 # via robocrys pure-eval==0.2.3 # via stack-data +py-key-value-aio[disk,keyring,memory,redis]==0.3.0 + # via + # fastmcp + # pydocket +py-key-value-shared==0.3.0 + # via py-key-value-aio pyarrow==22.0.0 # via emmet-core pybtex==0.25.1 @@ -284,9 +419,14 @@ pycodestyle==2.14.0 # via # flake8 # mp-api (pyproject.toml) -pydantic==2.12.5 +pycparser==2.23 + # via cffi +pydantic[email]==2.12.5 # via # emmet-core + # fastmcp + # mcp + # openapi-pydantic # pydantic-settings # pymatgen-io-validation pydantic-core==2.41.5 @@ -294,7 +434,10 @@ pydantic-core==2.41.5 pydantic-settings==2.12.0 # via # emmet-core + # mcp # pymatgen-io-validation +pydocket==0.16.6 + # via fastmcp pyflakes==3.4.0 # via flake8 pygments==2.19.2 @@ -302,13 +445,17 @@ pygments==2.19.2 # ipython # ipython-pygments-lexers # pytest + # rich # sphinx pyisemail==2.0.1 # via mpcontribs-client +pyjwt[crypto]==2.10.1 + # via mcp pymatgen==2025.10.7 # via # emmet-core # matminer + # mp-api # mp-api (pyproject.toml) # mp-pyrho # mpcontribs-client @@ -325,15 +472,17 @@ pymatgen-analysis-diffusion==2025.11.15 # via emmet-core pymatgen-io-validation==0.1.2 # via emmet-core -pymongo==4.15.5 +pymongo==4.16.0 # via # matminer # mpcontribs-client -pyparsing==3.2.5 +pyparsing==3.3.1 # via # bibtexparser # matplotlib -pytest==9.0.1 +pyperclip==1.11.0 + # via fastmcp +pytest==9.0.2 # via # mp-api (pyproject.toml) # pytest-asyncio @@ -357,7 +506,13 @@ python-dateutil==2.9.0.post0 # matplotlib # pandas python-dotenv==1.2.1 - # via pydantic-settings + # via + # fastmcp + # pydantic-settings +python-json-logger==4.0.0 + # via pydocket +python-multipart==0.0.21 + # via mcp pytz==2025.2 # via # bravado-core @@ -366,18 +521,27 @@ pyyaml==6.0.3 # via # bravado # bravado-core + # jsonschema-path # pre-commit # pybtex # swagger-spec-validator -referencing==0.37.0 +redis==7.1.0 + # via + # fakeredis + # py-key-value-aio + # pydocket +referencing==0.36.2 # via # jsonschema + # jsonschema-path # jsonschema-specifications requests==2.32.5 # via # bravado # bravado-core + # jsonschema-path # matminer + # mp-api # mp-api (pyproject.toml) # pymatgen # pymatgen-io-validation @@ -391,26 +555,33 @@ rfc3986-validator==0.1.1 # via jsonschema rfc3987-syntax==1.1.0 # via jsonschema +rich==14.2.0 + # via + # cyclopts + # fastmcp + # pydocket + # rich-rst + # typer +rich-rst==1.3.2 + # via cyclopts robocrys==0.2.12 # via emmet-core -roman-numerals==3.1.0 +roman-numerals==4.1.0 # via sphinx rpds-py==0.30.0 # via # jsonschema # referencing -ruamel-yaml==0.18.16 +ruamel-yaml==0.19.1 # via # custodian # monty # pymatgen -ruamel-yaml-clib==0.2.15 - # via ruamel-yaml s3transfer==0.16.0 # via boto3 -scikit-image==0.25.2 +scikit-image==0.26.0 # via pymatgen-analysis-defects -scikit-learn==1.7.2 +scikit-learn==1.8.0 # via matminer scipy==1.16.3 # via @@ -421,12 +592,16 @@ scipy==1.16.3 # scikit-learn seaborn==0.13.2 # via pymatgen-analysis-diffusion +secretstorage==3.5.0 + # via keyring seekpath==2.1.0 # via emmet-core semantic-version==2.10.0 # via mpcontribs-client shapely==2.1.2 # via pymatgen-analysis-alloys +shellingham==1.5.4 + # via typer simplejson==3.20.2 # via # bravado @@ -439,15 +614,19 @@ six==1.17.0 # python-dateutil # rfc3339-validator smart-open==7.5.0 - # via mp-api (pyproject.toml) + # via + # mp-api + # mp-api (pyproject.toml) snowballstemmer==3.0.1 # via sphinx -spglib==2.6.0 +sortedcontainers==2.4.0 + # via fakeredis +spglib==2.7.0 # via # pymatgen # robocrys # seekpath -sphinx==9.0.4 +sphinx==9.1.0 # via mp-api (pyproject.toml) sphinxcontrib-applehelp==2.0.0 # via sphinx @@ -461,8 +640,14 @@ sphinxcontrib-qthelp==2.0.0 # via sphinx sphinxcontrib-serializinghtml==2.0.0 # via sphinx +sse-starlette==3.1.2 + # via mcp stack-data==0.6.3 # via ipython +starlette==0.51.0 + # via + # mcp + # sse-starlette swagger-spec-validator==3.0.4 # via # bravado-core @@ -475,7 +660,7 @@ tabulate==0.9.0 # via pymatgen threadpoolctl==3.6.0 # via scikit-learn -tifffile==2025.10.16 +tifffile==2026.1.14 # via scikit-image tqdm==4.67.1 # via @@ -488,51 +673,75 @@ traitlets==5.14.3 # matplotlib-inline typeguard==4.4.4 # via inflect -types-requests==2.32.4.20250913 +typer==0.21.1 + # via pydocket +types-requests==2.32.4.20260107 # via mp-api (pyproject.toml) -types-setuptools==80.9.0.20250822 +types-setuptools==80.9.0.20251223 # via mp-api (pyproject.toml) typing-extensions==4.15.0 # via + # anyio # bravado # emmet-core + # exceptiongroup # flexcache # flexparser + # mcp + # mp-api # mp-api (pyproject.toml) # mypy + # opentelemetry-api + # opentelemetry-sdk + # opentelemetry-semantic-conventions # pint + # py-key-value-shared # pydantic # pydantic-core + # pydocket # pytest-asyncio # referencing # spglib + # starlette # swagger-spec-validator # typeguard + # typer # typing-inspection typing-inspection==0.4.2 # via + # mcp # pydantic # pydantic-settings -tzdata==2025.2 +tzdata==2025.3 # via # arrow # pandas ujson==5.11.0 # via mpcontribs-client -uncertainties==3.2.3 +uncertainties==3.2.4 # via pymatgen uri-template==1.3.0 # via jsonschema -urllib3==2.6.0 +urllib3==2.6.3 # via # botocore # requests # types-requests -virtualenv==20.35.4 +uvicorn==0.40.0 + # via + # fastmcp + # mcp +virtualenv==20.36.1 # via pre-commit wcwidth==0.2.14 # via prompt-toolkit webcolors==25.10.0 # via jsonschema -wrapt==2.0.1 - # via smart-open +websockets==16.0 + # via fastmcp +wrapt==1.17.3 + # via + # opentelemetry-instrumentation + # smart-open +zipp==3.23.0 + # via importlib-metadata diff --git a/tests/materials/__init__.py b/tests/client/__init__.py similarity index 100% rename from tests/materials/__init__.py rename to tests/client/__init__.py diff --git a/tests/conftest.py b/tests/client/conftest.py similarity index 100% rename from tests/conftest.py rename to tests/client/conftest.py diff --git a/tests/core/test_oxygen_evolution.py b/tests/client/core/test_oxygen_evolution.py similarity index 100% rename from tests/core/test_oxygen_evolution.py rename to tests/client/core/test_oxygen_evolution.py diff --git a/tests/core/test_utils.py b/tests/client/core/test_utils.py similarity index 100% rename from tests/core/test_utils.py rename to tests/client/core/test_utils.py diff --git a/tests/molecules/__init__.py b/tests/client/materials/__init__.py similarity index 100% rename from tests/molecules/__init__.py rename to tests/client/materials/__init__.py diff --git a/tests/materials/test_absorption.py b/tests/client/materials/test_absorption.py similarity index 100% rename from tests/materials/test_absorption.py rename to tests/client/materials/test_absorption.py diff --git a/tests/materials/test_alloys.py b/tests/client/materials/test_alloys.py similarity index 100% rename from tests/materials/test_alloys.py rename to tests/client/materials/test_alloys.py diff --git a/tests/materials/test_bonds.py b/tests/client/materials/test_bonds.py similarity index 100% rename from tests/materials/test_bonds.py rename to tests/client/materials/test_bonds.py diff --git a/tests/materials/test_chemenv.py b/tests/client/materials/test_chemenv.py similarity index 100% rename from tests/materials/test_chemenv.py rename to tests/client/materials/test_chemenv.py diff --git a/tests/materials/test_dielectric.py b/tests/client/materials/test_dielectric.py similarity index 100% rename from tests/materials/test_dielectric.py rename to tests/client/materials/test_dielectric.py diff --git a/tests/materials/test_doi.py b/tests/client/materials/test_doi.py similarity index 100% rename from tests/materials/test_doi.py rename to tests/client/materials/test_doi.py diff --git a/tests/materials/test_elasticity.py b/tests/client/materials/test_elasticity.py similarity index 100% rename from tests/materials/test_elasticity.py rename to tests/client/materials/test_elasticity.py diff --git a/tests/materials/test_electrodes.py b/tests/client/materials/test_electrodes.py similarity index 100% rename from tests/materials/test_electrodes.py rename to tests/client/materials/test_electrodes.py diff --git a/tests/materials/test_electronic_structure.py b/tests/client/materials/test_electronic_structure.py similarity index 100% rename from tests/materials/test_electronic_structure.py rename to tests/client/materials/test_electronic_structure.py diff --git a/tests/materials/test_eos.py b/tests/client/materials/test_eos.py similarity index 100% rename from tests/materials/test_eos.py rename to tests/client/materials/test_eos.py diff --git a/tests/materials/test_grain_boundary.py b/tests/client/materials/test_grain_boundary.py similarity index 100% rename from tests/materials/test_grain_boundary.py rename to tests/client/materials/test_grain_boundary.py diff --git a/tests/materials/test_magnetism.py b/tests/client/materials/test_magnetism.py similarity index 100% rename from tests/materials/test_magnetism.py rename to tests/client/materials/test_magnetism.py diff --git a/tests/materials/test_materials.py b/tests/client/materials/test_materials.py similarity index 100% rename from tests/materials/test_materials.py rename to tests/client/materials/test_materials.py diff --git a/tests/materials/test_oxidation_states.py b/tests/client/materials/test_oxidation_states.py similarity index 100% rename from tests/materials/test_oxidation_states.py rename to tests/client/materials/test_oxidation_states.py diff --git a/tests/materials/test_phonon.py b/tests/client/materials/test_phonon.py similarity index 100% rename from tests/materials/test_phonon.py rename to tests/client/materials/test_phonon.py diff --git a/tests/materials/test_piezo.py b/tests/client/materials/test_piezo.py similarity index 100% rename from tests/materials/test_piezo.py rename to tests/client/materials/test_piezo.py diff --git a/tests/materials/test_provenance.py b/tests/client/materials/test_provenance.py similarity index 100% rename from tests/materials/test_provenance.py rename to tests/client/materials/test_provenance.py diff --git a/tests/materials/test_robocrys.py b/tests/client/materials/test_robocrys.py similarity index 100% rename from tests/materials/test_robocrys.py rename to tests/client/materials/test_robocrys.py diff --git a/tests/materials/test_similarity.py b/tests/client/materials/test_similarity.py similarity index 100% rename from tests/materials/test_similarity.py rename to tests/client/materials/test_similarity.py diff --git a/tests/materials/test_substrates.py b/tests/client/materials/test_substrates.py similarity index 100% rename from tests/materials/test_substrates.py rename to tests/client/materials/test_substrates.py diff --git a/tests/materials/test_summary.py b/tests/client/materials/test_summary.py similarity index 100% rename from tests/materials/test_summary.py rename to tests/client/materials/test_summary.py diff --git a/tests/materials/test_surface_properties.py b/tests/client/materials/test_surface_properties.py similarity index 100% rename from tests/materials/test_surface_properties.py rename to tests/client/materials/test_surface_properties.py diff --git a/tests/materials/test_synthesis.py b/tests/client/materials/test_synthesis.py similarity index 100% rename from tests/materials/test_synthesis.py rename to tests/client/materials/test_synthesis.py diff --git a/tests/materials/test_tasks.py b/tests/client/materials/test_tasks.py similarity index 100% rename from tests/materials/test_tasks.py rename to tests/client/materials/test_tasks.py diff --git a/tests/materials/test_thermo.py b/tests/client/materials/test_thermo.py similarity index 100% rename from tests/materials/test_thermo.py rename to tests/client/materials/test_thermo.py diff --git a/tests/materials/test_xas.py b/tests/client/materials/test_xas.py similarity index 100% rename from tests/materials/test_xas.py rename to tests/client/materials/test_xas.py diff --git a/tests/client/molecules/__init__.py b/tests/client/molecules/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/molecules/core_function.py b/tests/client/molecules/core_function.py similarity index 100% rename from tests/molecules/core_function.py rename to tests/client/molecules/core_function.py diff --git a/tests/molecules/test_jcesr.py b/tests/client/molecules/test_jcesr.py similarity index 100% rename from tests/molecules/test_jcesr.py rename to tests/client/molecules/test_jcesr.py diff --git a/tests/molecules/test_summary.py b/tests/client/molecules/test_summary.py similarity index 100% rename from tests/molecules/test_summary.py rename to tests/client/molecules/test_summary.py diff --git a/tests/test_client.py b/tests/client/test_client.py similarity index 100% rename from tests/test_client.py rename to tests/client/test_client.py diff --git a/tests/test_core_client.py b/tests/client/test_core_client.py similarity index 100% rename from tests/test_core_client.py rename to tests/client/test_core_client.py diff --git a/tests/test_mprester.py b/tests/client/test_mprester.py similarity index 100% rename from tests/test_mprester.py rename to tests/client/test_mprester.py diff --git a/tests/mcp/__init__.py b/tests/mcp/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/mcp/test_server.py b/tests/mcp/test_server.py new file mode 100644 index 000000000..972d17320 --- /dev/null +++ b/tests/mcp/test_server.py @@ -0,0 +1,31 @@ +import asyncio +import pytest + +from mp_api.client.core.exceptions import MPRestError +from mp_api.mcp.server import get_core_mcp, parse_server_args + + +async def get_mcp_tools(): + return set(await get_core_mcp().get_tools()) + + +async def get_mcp_tool(tool_name): + return await get_core_mcp().get_tool(tool_name) + + +def test_mcp_server(): + assert asyncio.run(get_mcp_tools()) == {"fetch", "search"} + + search_tool = asyncio.run(get_mcp_tool("search")) + assert search_tool.parameters["properties"] == {"query": {"type": "string"}} + fetch_tool = asyncio.run(get_mcp_tool("fetch")) + assert fetch_tool.parameters["properties"] == {"idx": {"type": "string"}} + + +def test_server_cli(): + assert parse_server_args( + ["--port", "-500", "--host", "0.0.0", "--transport", "sse"] + ) == {"port": -500, "host": "0.0.0", "transport": "sse"} + + with pytest.raises(MPRestError, match="Invalid `transport="): + _ = parse_server_args(["--transport", "magic"]) diff --git a/tests/mcp/test_tools.py b/tests/mcp/test_tools.py new file mode 100644 index 000000000..d4a99b47e --- /dev/null +++ b/tests/mcp/test_tools.py @@ -0,0 +1,43 @@ +from re import search +from pymatgen.core import Composition + +from mp_api.mcp._schemas import SearchOutput, FetchResult, MaterialMetadata +from mp_api.mcp.tools import MPCoreMCP + + +def test_chem_sys_parsing(): + for user_input, expected_output in { + "mp-149": {}, + "LiFePO4": {"formula": Composition("LiFePO4").formula}, + "Cs-Cl": {"chemsys": "Cl-Cs"}, + }.items(): + assert ( + MPCoreMCP._validate_chemical_system_formula(user_input) == expected_output + ) + + +def test_core_tools(): + with MPCoreMCP() as mcp_tools: + search_results = mcp_tools.search("Ga-W") + fetch_results = mcp_tools.fetch("Ir2 Br6") + robo_desc_docs = mcp_tools.client.robocrys.search_docs( + material_ids=[*[doc.id for doc in search_results.results], fetch_results.id] + ) + + robo_descs = {doc["material_id"]: doc["description"] for doc in robo_desc_docs} + + assert isinstance(search_results, SearchOutput) + assert all( + isinstance(doc, FetchResult) + and doc.id.startswith("mp-") + and doc.metadata is None + and doc.title.startswith("mp-") + and doc.text == robo_descs[doc.id] + and doc.url == f"https://next-gen.materialsproject.org/materials/{doc.id}" + for doc in search_results.results + ) + + assert isinstance(fetch_results, FetchResult) + assert isinstance(fetch_results.metadata, MaterialMetadata) + assert isinstance(fetch_results.metadata.structurally_similar_materials, str) + assert fetch_results.text == robo_descs[fetch_results.id] diff --git a/tests/mcp/test_utils.py b/tests/mcp/test_utils.py new file mode 100644 index 000000000..fbd2fd63b --- /dev/null +++ b/tests/mcp/test_utils.py @@ -0,0 +1,27 @@ +import pytest + +from mp_api.client.core.exceptions import MPRestError, MPRestWarning +from mp_api.mcp.utils import _NeedsMPClient + + +def test_mix_in(): + test_class = _NeedsMPClient() + assert test_class.client.api_key is not None + assert not test_class.client.use_document_model + print(test_class.client.session.headers["user-agent"]) + assert test_class.client.session.headers["user-agent"].startswith("mp-mcp") + + with pytest.raises(MPRestError, match="Valid API keys are 32"): + test_class.update_user_api_key(30 * "a") + + with pytest.warns(MPRestWarning, match="Ignoring `monty_decode`"): + # Test that `use_document_model` is always enforced to be False, and user agent is included + test_class = _NeedsMPClient( + client_kwargs={ + "monty_decode": False, + "use_document_model": True, + "mute_progress_bars": True, + } + ) + assert not test_class.client.use_document_model + assert test_class.client.mute_progress_bars