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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -121,3 +121,4 @@ _autosummary

uv.lock
JANAF_*_data.json
.gemini
12 changes: 12 additions & 0 deletions dev/inspect_mcp.sh
Original file line number Diff line number Diff line change
@@ -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"
1 change: 1 addition & 0 deletions mp_api/mcp/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Get default MCP for Materials Project."""
314 changes: 314 additions & 0 deletions mp_api/mcp/_schemas.py
Original file line number Diff line number Diff line change
@@ -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")
56 changes: 56 additions & 0 deletions mp_api/mcp/mp_mcp.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
"""Define custom MCP tools for the Materials Project API."""
from __future__ import annotations

from urllib.parse import urljoin

import httpx
from fastmcp import FastMCP

from mp_api.mcp.tools import MPCoreMCP, MPMcpTools
from mp_api.mcp.utils import _NeedsMPClient

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 get_mcp() -> FastMCP:
"""MCP with finer depth of control over tool names."""
mp_mcp = FastMCP("Materials_Project_MCP")
mcp_tools = MPMcpTools()
for attr in {x for x in dir(mcp_tools) if x.startswith("get_")}:
mp_mcp.tool(getattr(mcp_tools, attr))

# Register tool to set the user's API key
mp_mcp.tool(mcp_tools.update_user_api_key)
return mp_mcp


def get_bootstrap_mcp() -> FastMCP:
"""Bootstrap an MP API MCP only from the OpenAPI spec."""
client = _NeedsMPClient().client

return FastMCP.from_openapi(
openapi_spec=httpx.get(urljoin(client.endpoint, "openapi.json")).json(),
client=httpx.AsyncClient(
base_url=client.endpoint,
headers={"x-api-key": client.api_key},
),
name="MP_OpenAPI_MCP",
)
Loading
Loading