#
tokens: 18952/50000 14/14 files
lines: off (toggle) GitHub
raw markdown copy
# Directory Structure

```
├── __init__.py
├── .gitignore
├── .python-version
├── docker-compose.yml
├── Dockerfile
├── LICENSE
├── pyproject.toml
├── README.md
├── requirements.txt
├── server
│   ├── __init__.py
│   ├── .ipynb
│   └── server.py
├── tests
│   └── __init__.py
├── utils
│   ├── __init__.py
│   ├── prompts_templates.py
│   └── utility_functions.py
└── uv.lock
```

# Files

--------------------------------------------------------------------------------
/.python-version:
--------------------------------------------------------------------------------

```
3.12

```

--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------

```
.venv
.env
__pycache__
.idea/*
.vscode/*
.vscode/mcp.json
markdown_folder/
.vscode/

```

--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------

```markdown
# Materials Project MCP

A Model Context Protocol (MCP) server for querying the Materials Project database using the mp_api client.

## Requirements

- **Materials Project API Key** - [Get one here](https://materialsproject.org/) (free account required)
- **Docker Desktop** (must be running)
- **Python 3.12+** with [uv](https://github.com/astral-sh/uv)



### Getting Your Materials Project API Key

1. Visit [Materials Project](https://materialsproject.org/)
2. Create a free account or log in
3. Go to your dashboard
4. Navigate to API settings
5. Generate or copy your API key
6. Keep this key secure - you'll need it for setup

## Installation Options

### Step 1: Docker (Recommended)

#### Using Docker Run
1. **Install Docker Desktop:**
   - Download from [docker.com](https://www.docker.com/products/docker-desktop/)
   - Install and **make sure Docker Desktop is running**

2. **Pull the Docker image:**
   ```bash
   docker pull benedict2002/materials-project-mcp
   ```

3. **Test the installation:**
   ```bash
   docker run --rm -i -e MP_API_KEY="your-api-key" benedict2002/materials-project-mcp
   ```

#### Using Docker Compose (Easiest)
1. **Install Docker Desktop and make sure it's running**

2. **Clone the repository:**
   ```bash
   git clone <repository-url>
   cd materials-project-mcp
   ```

3. **Create a `.env` file:**
   ```bash
   echo "MP_API_KEY=your-materials-project-api-key" > .env
   ```

4. **Test the setup:**
   ```bash
   docker-compose up
   ```

5. **For background running:**
   ```bash
   docker-compose up -d
   ```

6. **Stop the service:**
   ```bash
   docker-compose down
   ```

### Step 1 : Local Python Installation

1. **Install uv (if not already installed):**
   ```bash
   curl -Ls https://astral.sh/uv/install.sh | sh
   ```

2. **Clone the repository:**
   ```bash
   git clone <repository-url>
   cd materials-project-mcp
   ```

3. **Create and activate virtual environment:**
   ```bash
   uv venv
   source .venv/bin/activate  # Linux/macOS
   # or
   .venv\Scripts\activate     # Windows
   ```

4. **Install dependencies:**
   ```bash
   uv pip install -r requirements.txt
   ```

5. **Set your API key:**
   ```bash
   export MP_API_KEY="your-api-key"  # Linux/macOS
   # or
   set MP_API_KEY=your-api-key       # Windows
   ```

6. **Test the installation:**
   ```bash
   python server.py
   ```

## Step 2 : Setup with Claude Desktop

1. **Locate your Claude configuration file:**
   - **macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json`
   - **Windows**: `%APPDATA%\Claude\claude_desktop_config.json`

2. **Choose your configuration method:**

   **Using Docker Run**
   ```json
   {
     "mcpServers": {
       "Materials Project MCP": {
         "command": "docker",
         "args": [
           "run", "--rm", "-i",
           "-e", "MP_API_KEY=your-materials-project-api-key",
           "benedict2002/materials-project-mcp"
         ]
       }
     }
   }
   ```

3. **Replace `your-materials-project-api-key` with your actual API key**

4. **Ensure Docker Desktop is running**

5. **Restart Claude Desktop**

6. **Verify installation:**
   - Open a new chat in Claude
   - Ask something like "Search for silicon materials in the Materials Project database" or test any of the availabe tools.
   - You should see Materials Project data in the response

## Setup with VS Code Copilot

1. **Open VS Code Settings:**
   - Press `Ctrl+Shift+P` (Windows/Linux) or `Cmd+Shift+P` (macOS)
   - Type "Preferences: Open User Settings (JSON)"
   - Select it to open settings.json

2. **Add MCP configuration:**
   ```json
   {
     "mcp": {
       "inputs": [],
       "servers": {
         "Materials Project MCP": {
           "command": "docker",
           "args": [
             "run", "--rm", "-i",
             "-e", "MP_API_KEY=your-api-key",
             "benedict2002/materials-project-mcp"
           ]
         }
       }
     },
     "chat.mcp.discovery.enabled": true,
     "workbench.secondarySideBar.showLabels": false
   }
   ```

3. **Alternative: Local Python setup for VS Code:**
   ```json
   {
     "mcp": {
       "inputs": [],
       "servers": {
         "Materials Project MCP": {
           "command": "/usr/local/bin/uv",
           "args": [
             "run",
             "--with",
             "mcp[cli],aiohttp,pydantic,mp_api,pymatgen,emmet-core",
             "/path/to/your/server.py"
           ],
           "env": {
             "MP_API_KEY": "your-api-key"
           }
         }
       }
     },
     "chat.mcp.discovery.enabled": true
   }
   ```

4. **Replace placeholders:**
   - `your-api-key` with your Materials Project API key
   - `/path/to/your/server.py` with the actual path to server.py

5. **Ensure Docker Desktop is running** (for Docker configurations)

6. **Restart VS Code**

7. **Test in VS Code:**
   - Open VS Code chat/copilot
   - Ask about materials from the Materials Project
   - The Docker container will start automatically when VS Code makes requests


## Testing & Development (developers)

### Testing Your Installation

1. **Test MCP server locally:**
   ```bash
   mcp dev server.py
   ```
   Look for the line "🔗 Open inspector with token pre-filled:" and use that URL

### Development Workflow

1. **Create a feature branch:**
   ```bash
   git checkout -b feature-name
   ```

2. **Make your changes and test:**
   ```bash
   # Local testing with MCP Inspector
   mcp dev server.py
   # Use the inspector URL to test your changes interactively
   
   # Docker testing
   docker build -t materials-project-mcp-local .
   docker run --rm -i -e MP_API_KEY="your-api-key" materials-project-mcp-local
   
   # Docker Compose testing
   docker-compose up --build
   ```

3. **Commit and push:**
   ```bash
   git add .
   git commit -m "Add feature description"
   git push origin feature-name
   ```

4. **Open a pull request**


## Available Tools & Features

- **search_materials** - Search by elements, band gap range, stability
- **get_structure_by_id** - Get crystal structures and lattice parameters
- **get_electronic_bandstructure** - Plot electronic band structures
- **get_electronic_dos_by_id** - Get electronic density of states
- **get_phonon_bandstructure** - Plot phonon band structures
- **get_phonon_dos_by_id** - Get phonon density of states
- **get_ion_reference_data_for_chemsys** - Download aqueous ion reference data for Pourbaix diagrams
- **get_cohesive_energy** - Calculate cohesive energies
- **get_atom_reference_data** - Retrieve reference energies of isolated neutral atoms
- **get_magnetic_data_by_id** - Magnetic properties and ordering
- **get_charge_density_by_id** - Charge density data
- **get_dielectric_data_by_id** - Dielectric constants and properties
- **get_diffraction_patterns** - X-ray and neutron diffraction
- **get_xRay_absorption_spectra** - XAFS, XANES, EXAFS spectra
- **get_elastic_constants** - Mechanical properties
- **get_suggested_substrates** - Find substrates for thin films
- **get_thermo_stability** - Thermodynamic stability analysis
- **get_surface_properties** - Surface energies, work functions, and Wulff shapes
- **get_grain_boundaries** - Computed grain boundaries for a material
- **get_insertion_electrodes** - Insertion electrode and battery data
- **get_oxidation_states** - Element oxidation states, formula, and structure info

## Troubleshooting

### Common Issues

1. **"Invalid API key" error:**
   - Verify your API key is correct
   - Check that you've set the environment variable properly
   - Ensure your Materials Project account is active

2. **"Docker not found" or "Cannot connect to Docker daemon":**
   - **Make sure Docker Desktop is installed and running**
   - You should see the Docker Desktop icon in your system tray/menu bar
   - Try `docker --version` to verify Docker is accessible
   - On Windows/Mac: Open Docker Desktop application
   - On Linux: Start Docker service with `sudo systemctl start docker`

3. **Container startup issues:**
   - Docker containers start automatically when Claude/VS Code makes requests
   - No need to manually start containers - they're ephemeral (start → run → stop)
   - Each query creates a fresh container instance

4. **Docker Compose issues:**
   - Make sure Docker Compose is installed: `docker-compose --version`
   - Check your `.env` file exists and has the correct API key
   - Verify the docker-compose.yml file is in the correct location
   - Ensure Docker Desktop is running

5. **MCP server not recognized in Claude:**
   - Check your configuration file path
   - Verify JSON syntax is correct
   - Restart Claude Desktop after configuration changes
   - Ensure Docker Desktop is running


### Getting Help

- **MCP Inspector**: Use `mcp dev server.py` for interactive testing and debugging.
- **GitHub Issues**: [Create an Issue](https://github.com/yourusername/materials-project-mcp/issues) for bug reports, feature requests, or questions.
- **Materials Project API Docs**: [docs.materialsproject.org](https://docs.materialsproject.org/)
- **MCP Documentation**: [modelcontextprotocol.io](https://modelcontextprotocol.io/)
- **Docker Help**: [docs.docker.com](https://docs.docker.com/)

---
### Authors

- Benedict Debrah
- Peniel Fiawornu

### Reference

Yin, Xiangyu. 2025. "Building an MCP Server for the Materials Project." March 23, 2025. https://xiangyu-yin.com/content/post_mp_mcp.html.
```

--------------------------------------------------------------------------------
/__init__.py:
--------------------------------------------------------------------------------

```python

```

--------------------------------------------------------------------------------
/server/__init__.py:
--------------------------------------------------------------------------------

```python

```

--------------------------------------------------------------------------------
/tests/__init__.py:
--------------------------------------------------------------------------------

```python

```

--------------------------------------------------------------------------------
/utils/__init__.py:
--------------------------------------------------------------------------------

```python

```

--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------

```yaml
services:
  materials-project-mcp:
    image: benedict2002/materials-project-mcp:latest
    container_name: materials-mcp
    environment:
      - MP_API_KEY=${MP_API_KEY}
    restart: unless-stopped
    stdin_open: true
    tty: true
```

--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------

```dockerfile
FROM python:3.12-slim

# Install uv
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/

# Copy the application into the container
COPY . /app

# Install the application dependencies
WORKDIR /app
RUN uv sync --frozen --no-cache

# Run server.py directly
CMD ["/app/.venv/bin/python", "/app/server/server.py"]
```

--------------------------------------------------------------------------------
/utils/utility_functions.py:
--------------------------------------------------------------------------------

```python
from pydantic import Field
from dataclasses import dataclass
from pathlib import Path
from datetime import datetime
from typing import Dict, List
import os



MARKDOWN_FOLDER = Path(os.path.dirname(__file__)).parent / "markdown_folder"
print(MARKDOWN_FOLDER)

@dataclass
class MarkdownFile: 
    name: str 
    path: Path 
    content: str = Field(default_factory=str)


class MarkdownResourceManager:
    def __init__(self, folder_path: Path):
        self.folder_path = folder_path
        self.files: Dict[str, MarkdownFile] = {}


    def load_files(self): 
        """"Load all markdown files from the folder path"""

        self.files.clear()

        if not self.folder_path.exists():
            print(f"Folder {self.folder_path} does not exist.")
            return "Folder does not exist."
        
        for file_path in self.folder_path.glob("*.md"):
            try: 
                content = file_path.read_text(encoding="utf-8")
                last_modified = datetime.fromtimestamp(file_path.stat().st_mtime)
                markdown_file = MarkdownFile(
                    name=file_path.stem,
                    path=file_path,
                    content=content,
                 #   last_modified=last_modified
                )
                self.files[file_path.stem] = markdown_file
            except Exception as e:
                print(f"Error reading file {file_path}: {e}")


if __name__ == "__main__":
    manager = MarkdownResourceManager(MARKDOWN_FOLDER)
    manager.load_files()
    print(manager.files["docsmaterialsproject"].content)

```

--------------------------------------------------------------------------------
/utils/prompts_templates.py:
--------------------------------------------------------------------------------

```python
from dataclasses import dataclass




@dataclass
class ElectronicBandStructurePrompt:
    """
    Dataclass to store the prompt template for electronic band structure tool usage.
    Contains the comprehensive template with instructions, parameters, and examples.
    """
    template: str = """
        # Electronic Band Structure Tool Usage Guide

        ## Tool Overview
        The get_electronic_bandstructure tool generates electronic band structure plots for materials from the Materials Project database. It returns the plot as a base64-encoded PNG image within a JSON response structure.

        ## Tool Parameters
        - material_id (required): Materials Project ID (e.g., 'mp-149', 'mp-22526')
        - path_type (optional): K-point path type:
        - 'setyawan_curtarolo' (default) - Standard path for cubic systems
        - 'hinuma' - Standard path for hexagonal systems
        - 'latimer_munro' - Alternative path for cubic systems
        - 'uniform' - Uniform k-point sampling (not recommended for plotting)

        ## Expected Output Format
        The tool returns a JSON object with the following structure:
        
        {
        "success": true,
        "material_id": "mp-149",
        "image_base64": "iVBORw0KGgoAAAANSUhEUgAAB...[~500KB-2MB base64 string]",
        "metadata": {
            "path_type": "setyawan_curtarolo",
            "description": "Band structure plot for material mp-149 using setyawan_curtarolo path",
            "width": 1200,
            "height": 800
        }
        }
        

        Note: The image_base64 field contains a very long base64 string (typically 500KB-2MB). For brevity, examples show truncated versions.

        ## How to Parse and Display the Response

        ### Method 1: Extract and Display Base64 Image (Python)
        
        import json
        import base64
        from PIL import Image
        import io
        import matplotlib.pyplot as plt

        def display_bandstructure(response):
            # Parse the JSON response
            data = json.loads(response) if isinstance(response, str) else response
            
            if not data.get("success"):
                print("Error: Tool execution failed")
                return
            
            # Extract base64 image data (this will be a very long string)
            image_base64 = data["image_base64"]
            material_id = data["material_id"]
            metadata = data["metadata"]
            
            # Decode base64 to image
            image_bytes = base64.b64decode(image_base64)
            image = Image.open(io.BytesIO(image_bytes))
            
            # Display the image
            plt.figure(figsize=(12, 8))
            plt.imshow(image)
            plt.axis('off')
            plt.title(f"Band Structure: {material_id} ({metadata['path_type']})")
            plt.tight_layout()
            plt.show()
            
            print(f"Material ID: {material_id}")
            print(f"Path Type: {metadata['path_type']}")
            print(f"Image Size: {metadata['width']} × {metadata['height']}")
        
        """
```

--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------

```
aiohappyeyeballs==2.6.1
aiohttp==3.11.18
aioitertools==0.12.0
aiosignal==1.3.2
annotated-types==0.7.0
anyio==4.9.0
appnope==0.1.4
arrow==1.3.0
ase==3.25.0
asttokens==3.0.0
atomate==1.1.0
attrs==25.3.0
bcrypt==4.3.0
bibtexparser==1.4.3
blinker==1.9.0
boltons==25.0.0
boto3==1.38.17
botocore==1.38.17
bravado==12.0.1
bravado-core==6.1.1
brewer2mpl==1.4.1
cachetools==6.1.0
castepxbin==0.3.0
certifi==2025.4.26
cffi==1.17.1
charset-normalizer==3.4.2
click==8.2.0
colormath==3.0.0
comm==0.2.2
contourpy==1.3.2
cryptography==44.0.3
custodian==2025.5.12
cycler==0.12.1
debugpy==1.8.14
decorator==5.2.1
dnspython==2.7.0
dotenv==0.9.9
emmet==2018.6.7
emmet-core==0.84.6
executing==2.2.0
fastapi==0.115.13
filetype==1.2.0
fireworks==2.0.4
flask==3.1.1
flask-paginate==2024.4.12
flatten-dict==0.4.2
flexcache==0.3
flexparser==0.4
fonttools==4.58.0
fqdn==1.5.1
frozenlist==1.6.0
gunicorn==23.0.0
h11==0.16.0
h5py==3.13.0
httpcore==1.0.9
httpx==0.28.1
httpx-sse==0.4.0
idna==3.10
imageio==2.37.0
importlib-resources==6.5.2
ipykernel==6.29.5
ipython==9.2.0
ipython-pygments-lexers==1.1.1
isoduration==20.11.0
itsdangerous==2.2.0
jedi==0.19.2
jinja2==3.1.6
jmespath==1.0.1
joblib==1.5.0
json2html==1.3.0
jsonlines==4.0.0
jsonpointer==3.0.0
jsonref==1.1.0
jsonschema==4.23.0
jsonschema-specifications==2025.4.1
jupyter-client==8.6.3
jupyter-core==5.7.2
kiwisolver==1.4.8
latexcodec==3.0.0
lazy-loader==0.4
maggma==0.71.5
markdown-it-py==3.0.0
markupsafe==3.0.2
matminer==0.9.3
matplotlib==3.10.3
matplotlib-inline==0.1.7
mcp==1.8.1
mdurl==0.1.2
mongomock==4.3.0
monotonic==1.6
monty==2025.3.3
mp-api==0.45.5
mp-pyrho==0.4.5
mpcontribs-client==5.10.2
mpmath==1.3.0
msgpack==1.1.0
multidict==6.4.3
narwhals==1.39.1
nest-asyncio==1.6.0
networkx==3.4.2
numpy==1.26.4
orjson==3.10.18
packaging==25.0
palettable==3.3.3
pandas==2.2.3
paramiko==3.5.1
parso==0.8.4
pexpect==4.9.0
phonopy==2.38.2
pillow==11.2.1
pint==0.24.4
platformdirs==4.3.8
plotly==6.1.0
prettyplotlib==0.1.7
prompt-toolkit==3.0.51
propcache==0.3.1
psutil==7.0.0
ptyprocess==0.7.0
pure-eval==0.2.3
pybtex==0.24.0
pycparser==2.22
pydantic==2.11.4
pydantic-core==2.33.2
pydantic-settings==2.9.1
pydash==8.0.5
pygments==2.19.1
pyisemail==2.0.1
pymatgen==2025.1.9
pymatgen-analysis-defects==2025.1.18
pymatgen-analysis-diffusion==2024.7.15
pymongo==4.10.1
pynacl==1.5.0
pyparsing==3.2.3
python-dateutil==2.9.0.post0
python-dotenv==1.1.0
python-multipart==0.0.20
pytz==2025.2
pyyaml==6.0.2
pyzmq==26.4.0
referencing==0.36.2
requests==2.32.3
requests-futures==1.0.2
rfc3339-validator==0.1.4
rfc3986-validator==0.1.1
rich==14.0.0
rpds-py==0.25.0
ruamel-yaml==0.18.10
ruamel-yaml-clib==0.2.12
s3transfer==0.12.0
scikit-image==0.25.2
scikit-learn==1.6.1
scipy==1.15.3
seaborn==0.13.2
seekpath==2.1.0
semantic-version==2.10.0
sentinels==1.0.0
setuptools==80.7.1
shellingham==1.5.4
simplejson==3.20.1
six==1.17.0
smart-open==7.1.0
sniffio==1.3.1
spglib==2.6.0
sse-starlette==2.3.5
sshtunnel==0.4.0
stack-data==0.6.3
starlette==0.46.2
sumo==2.3.12
swagger-spec-validator==3.0.4
symfc==1.3.4
sympy==1.14.0
tabulate==0.9.0
threadpoolctl==3.6.0
tifffile==2025.5.10
tornado==6.4.2
tqdm==4.67.1
traitlets==5.14.3
typer==0.15.3
types-python-dateutil==2.9.0.20250516
typing-extensions==4.13.2
typing-inspection==0.4.0
tzdata==2025.2
ujson==5.10.0
uncertainties==3.2.3
uri-template==1.3.0
urllib3==2.4.0
uvicorn==0.34.2
wcwidth==0.2.13
webcolors==24.11.1
werkzeug==3.1.3
wrapt==1.17.2
yarl==1.20.0
beautifulsoup4==4.13.4
soupsieve==2.7

```

--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------

```toml
[project]
name = "materials-project-mcp"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
    "aiohappyeyeballs==2.6.1",
    "aiohttp==3.11.18",
    "aioitertools==0.12.0",
    "aiosignal==1.3.2",
    "annotated-types==0.7.0",
    "anyio==4.9.0",
    "appnope==0.1.4",
    "ase==3.25.0",
    "asttokens==3.0.0",
    "atomate==1.1.0",
    "attrs==25.3.0",
    "bcrypt==4.3.0",
    "bibtexparser==1.4.3",
    "blinker==1.9.0",
    "boto3==1.38.17",
    "botocore==1.38.17",
    "brewer2mpl==1.4.1",
    "castepxbin==0.3.0",
    "certifi==2025.4.26",
    "cffi==1.17.1",
    "charset-normalizer==3.4.2",
    "click==8.2.0",
    "colormath==3.0.0",
    "comm==0.2.2",
    "contourpy==1.3.2",
    "cryptography==44.0.3",
    "custodian==2025.5.12",
    "cycler==0.12.1",
    "debugpy==1.8.14",
    "decorator==5.2.1",
    "dnspython==2.7.0",
    "dotenv>=0.9.9",
    "emmet==2018.6.7",
    "emmet-core==0.84.6",
    "executing==2.2.0",
    "fastapi>=0.115.13",
    "fireworks==2.0.4",
    "flask==3.1.1",
    "flask-paginate==2024.4.12",
    "fonttools==4.58.0",
    "frozenlist==1.6.0",
    "gunicorn==23.0.0",
    "h11==0.16.0",
    "h5py==3.13.0",
    "httpcore==1.0.9",
    "httpx==0.28.1",
    "httpx-sse==0.4.0",
    "idna==3.10",
    "imageio==2.37.0",
    "importlib-resources==6.5.2",
    "ipykernel==6.29.5",
    "ipython==9.2.0",
    "ipython-pygments-lexers==1.1.1",
    "itsdangerous==2.2.0",
    "jedi==0.19.2",
    "jinja2==3.1.6",
    "jmespath==1.0.1",
    "joblib==1.5.0",
    "jsonlines==4.0.0",
    "jsonschema==4.23.0",
    "jsonschema-specifications==2025.4.1",
    "jupyter-client==8.6.3",
    "jupyter-core==5.7.2",
    "kiwisolver==1.4.8",
    "latexcodec==3.0.0",
    "lazy-loader==0.4",
    "maggma==0.71.5",
    "markdown-it-py==3.0.0",
    "markupsafe==3.0.2",
    "matminer==0.9.3",
    "matplotlib==3.10.3",
    "matplotlib-inline==0.1.7",
    "mcp[cli]==1.8.1",
    "mdurl==0.1.2",
    "mongomock==4.3.0",
    "monty==2025.3.3",
    "mp-api==0.45.5",
    "mp-pyrho==0.4.5",
    "mpcontribs-client>=5.10.2",
    "mpmath==1.3.0",
    "msgpack==1.1.0",
    "multidict==6.4.3",
    "narwhals==1.39.1",
    "nest-asyncio==1.6.0",
    "networkx==3.4.2",
    "numpy==1.26.4",
    "orjson==3.10.18",
    "packaging==25.0",
    "palettable==3.3.3",
    "pandas==2.2.3",
    "paramiko==3.5.1",
    "parso==0.8.4",
    "pexpect==4.9.0",
    "phonopy==2.38.2",
    "pillow==11.2.1",
    "platformdirs==4.3.8",
    "plotly==6.1.0",
    "prettyplotlib==0.1.7",
    "prompt-toolkit==3.0.51",
    "propcache==0.3.1",
    "psutil==7.0.0",
    "ptyprocess==0.7.0",
    "pure-eval==0.2.3",
    "pybtex==0.24.0",
    "pycparser==2.22",
    "pydantic==2.11.4",
    "pydantic-core==2.33.2",
    "pydantic-settings==2.9.1",
    "pydash==8.0.5",
    "pygments==2.19.1",
    "pymatgen==2025.1.9",
    "pymatgen-analysis-defects==2025.1.18",
    "pymatgen-analysis-diffusion==2024.7.15",
    "pymongo==4.10.1",
    "pynacl==1.5.0",
    "pyparsing==3.2.3",
    "python-dateutil==2.9.0.post0",
    "python-dotenv==1.1.0",
    "python-multipart==0.0.20",
    "pytz==2025.2",
    "pyyaml==6.0.2",
    "pyzmq==26.4.0",
    "referencing==0.36.2",
    "requests==2.32.3",
    "rich==14.0.0",
    "rpds-py==0.25.0",
    "ruamel-yaml==0.18.10",
    "ruamel-yaml-clib==0.2.12",
    "s3transfer==0.12.0",
    "scikit-image==0.25.2",
    "scikit-learn==1.6.1",
    "scipy==1.15.3",
    "seaborn==0.13.2",
    "seekpath==2.1.0",
    "sentinels==1.0.0",
    "setuptools==80.7.1",
    "shellingham==1.5.4",
    "six==1.17.0",
    "smart-open==7.1.0",
    "sniffio==1.3.1",
    "spglib==2.6.0",
    "sse-starlette==2.3.5",
    "sshtunnel==0.4.0",
    "stack-data==0.6.3",
    "starlette==0.46.2",
    "sumo==2.3.12",
    "symfc==1.3.4",
    "sympy==1.14.0",
    "tabulate==0.9.0",
    "threadpoolctl==3.6.0",
    "tifffile==2025.5.10",
    "tornado==6.4.2",
    "tqdm==4.67.1",
    "traitlets==5.14.3",
    "typer==0.15.3",
    "typing-extensions==4.13.2",
    "typing-inspection==0.4.0",
    "tzdata==2025.2",
    "uncertainties==3.2.3",
    "urllib3==2.4.0",
    "uvicorn==0.34.2",
    "wcwidth==0.2.13",
    "werkzeug==3.1.3",
    "wrapt==1.17.2",
    "yarl==1.20.0",
]

```

--------------------------------------------------------------------------------
/server/server.py:
--------------------------------------------------------------------------------

```python
import os
import logging
from typing import Optional, List, Union
from mcp.server.fastmcp import FastMCP
from pydantic import Field, AnyUrl
import matplotlib.pyplot as plt
from pymatgen.electronic_structure.plotter import BSPlotter
from pymatgen.electronic_structure.bandstructure import BandStructureSymmLine
from pymatgen.symmetry.analyzer import SpacegroupAnalyzer
from pymatgen.analysis.diffraction.xrd import XRDCalculator
from pymatgen.phonon.bandstructure import PhononBandStructureSymmLine
from pymatgen.phonon.plotter import PhononBSPlotter
from pymatgen.analysis.wulff import WulffShape
from emmet.core.electronic_structure import BSPathType
from typing import Literal
from dotenv import load_dotenv 
from mp_api.client import MPRester
import io 
import base64
import sys  


sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
# Setup logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("materials_project_mcp")
load_dotenv()
API_KEY = os.environ.get("MP_API_KEY")

# Create the MCP server instance
mcp = FastMCP()


def _get_mp_rester() -> MPRester:
    """
    Initialize and return a MPRester session with the user's API key.
    
    Returns:
        MPRester: An authenticated MPRester instance for querying the Materials Project API.
        
    Note:
        If no API key is found in environment variables, attempts to initialize without key.
    """
    if not API_KEY:
        logger.warning(
            "No MP_API_KEY found in environment. Attempting MPRester() without key."
        )
        return MPRester()
    return MPRester(API_KEY)


@mcp.tool()
async def search_materials(
    elements: Optional[List[str]] = Field(
        default=None,
        description="List of element symbols to filter by (e.g. ['Si', 'O']). If None, searches across all elements.",
    ),
    band_gap_min: float = Field(
        default=0.0, 
        description="Lower bound for band gap filtering in eV. Materials with band gaps below this value will be excluded.",
    ),
    band_gap_max: float = Field(
        default=10.0, 
        description="Upper bound for band gap filtering in eV. Materials with band gaps above this value will be excluded.",
    ),
    is_stable: bool = Field(
        default=False,
        description="If True, only returns materials that are thermodynamically stable (energy above hull = 0). If False, returns all materials.",
    ),
    max_results: int = Field(
        default=50, 
        ge=1, 
        le=200, 
        description="Maximum number of results to return. Must be between 1 and 200.",
    ),
) -> str:
    """
    Search for materials in the Materials Project database using various filters.
    
    This function allows searching for materials based on their elemental composition,
    band gap range, and thermodynamic stability. Results are returned in a formatted
    markdown string containing material IDs, formulas, band gaps, and energy above hull values.
    
    Args:
        elements: Optional list of element symbols to filter by (e.g. ['Si', 'O'])
        band_gap_min: Minimum band gap in eV (default: 0.0)
        band_gap_max: Maximum band gap in eV (default: 10.0)
        is_stable: Whether to only return stable materials (default: False)
        max_results: Maximum number of results to return (default: 50, max: 200)
        
    Returns:
        str: A formatted markdown string containing the search results
        
    Example:
        >>> search_materials(elements=['Si', 'O'], band_gap_min=1.0, band_gap_max=5.0)
        Returns materials containing Si and O with band gaps between 1 and 5 eV
    """
    logger.info("Starting search_materials query...")
    with _get_mp_rester() as mpr:
        docs = mpr.materials.summary.search(
            elements=elements,
            band_gap=(band_gap_min, band_gap_max),
            is_stable=is_stable,
            fields=["material_id", "formula_pretty", "band_gap", "energy_above_hull"],
        )

    # Truncate results to max_results
    docs = list(docs)[:max_results]

    if not docs:
        return "No materials found matching your criteria."

    results_md = (
        f"## Materials Search Results\n\n"
        f"- **Elements**: {elements or 'Any'}\n"
        f"- **Band gap range**: {band_gap_min} eV to {band_gap_max} eV\n"
        f"- **Stable only**: {is_stable}\n\n"
        f"**Showing up to {max_results} matches**\n\n"
    )
    for i, mat in enumerate(docs, 1):
        results_md += (
            f"**{i}.** ID: `{mat.material_id}` | Formula: **{mat.formula_pretty}** | "
            f"Band gap: {mat.band_gap:.3f} eV | E above hull: {mat.energy_above_hull:.3f} eV\n"
        )
    return results_md


@mcp.tool()
async def get_structure_by_id(
    material_id: str = Field(
        ..., 
        description="Materials Project ID (e.g. 'mp-149'). Must be a valid MP ID."
    )
) -> str:
    """
    Retrieve and format the crystal structure for a given material from the Materials Project.
    
    This function fetches the final computed structure for a material and returns a
    formatted summary including the lattice parameters, number of sites, and chemical formula.
    
    Args:
        material_id: The Materials Project ID of the material (e.g. 'mp-149')
        
    Returns:
        str: A formatted markdown string containing the structure information
        
    Example:
        >>> get_structure_by_id('mp-149')
        Returns the crystal structure information for silicon (mp-149)
    """
    logger.info(f"Fetching structure for {material_id}...")
    with _get_mp_rester() as mpr:
        structure = mpr.get_structure_by_material_id(material_id)

    if not structure:
        return f"No structure found for {material_id}."

    formula = structure.composition.reduced_formula
    lattice = structure.lattice
    sites_count = len(structure)
    text_summary = (
        f"## Structure for {material_id}\n\n"
        f"- **Formula**: {formula}\n"
        f"- **Lattice**:\n"
        f"   a = {lattice.a:.3f} Å, b = {lattice.b:.3f} Å, c = {lattice.c:.3f} Å\n"
        f"   α = {lattice.alpha:.2f}°, β = {lattice.beta:.2f}°, γ = {lattice.gamma:.2f}°\n"
        f"- **Number of sites**: {sites_count}\n"
        f"- **Reduced formula**: {structure.composition.reduced_formula}\n"
    )
    return text_summary


@mcp.tool()
async def get_electronic_bandstructure(
    material_id: str = Field(
        ..., 
        description="Materials Project ID (e.g. 'mp-149'). Must be a valid MP ID."
    ),
    path_type: Literal["setyawan_curtarolo", "hinuma", "latimer_munro", "uniform"] = Field(
        default="setyawan_curtarolo",
        description="Type of k-point path to use for the band structure plot. Options are:\n"
                   "- setyawan_curtarolo: Standard path for cubic systems\n"
                   "- hinuma: Standard path for hexagonal systems\n"
                   "- latimer_munro: Alternative path for cubic systems\n"
                   "- uniform: Uniform k-point sampling (not recommended for plotting)"
    ),
):
    """
    Generate and return a electronic band structure plot for a given material.
    
    This function fetches the band structure data from the Materials Project and creates
    a plot showing the electronic band structure along high-symmetry k-points. The plot
    is returned as a base64-encoded PNG image embedded in a markdown string.
    
    Args:
        material_id: The Materials Project ID of the material (e.g. 'mp-149')
        path_type: The type of k-point path to use for the band structure plot
        
    Returns:
        A plot of the electronic band structure 
        
    Example:
        >>> get_electronic_bandstructure('mp-149', path_type='setyawan_curtarolo')
        Returns a band structure plot for silicon using the standard cubic path
    """
    logger.info(f"Plotting band structure for {material_id} with path_type: {path_type}")

    with _get_mp_rester() as mpr:
        if path_type == "uniform":
            bs = mpr.get_bandstructure_by_material_id(material_id, line_mode=False)
        else:
            bs = mpr.get_bandstructure_by_material_id(
                material_id, path_type=BSPathType(path_type)
            )

    if not isinstance(bs, BandStructureSymmLine):
        return f"Cannot plot `{path_type}` band structure. Only line-mode paths are plottable."
    
    # Generate the plot 
    plotter = BSPlotter(bs)
    ax = plotter.get_plot()
    fig = ax.get_figure()  
    


    plt.title(f"Band Structure for {material_id} using {path_type} path")   
    plt.ylabel("Energy (eV)")
    plt.tight_layout()
    #plot the image 
    # Save the figure to a buffer
    buffer = io.BytesIO()
    fig.savefig(buffer, format='png', dpi=150, bbox_inches='tight')
    buffer.seek(0)
    #plt.close(fig)  # Close the figure to free memory
    # return image object 
    return fig

    


    ## save to buffer 
    #buffer = io.BytesIO()
    #fig.savefig(buffer, format='png', dpi=70, bbox_inches='tight')
    #plt.close(fig)  # Close the figure to free memory

    ## figure dimensions 
    #fig_width = fig.get_figwidth() * fig.dpi
    #fig_height = fig.get_figheight() * fig.dpi


    #band_image_data = buffer.getvalue()
    #image_base64 = base64.b64encode(band_image_data).decode('ascii')

    #return {
    #    "success": True,   
    #    "material_id": material_id,
    #    "image_base64": image_base64[:40000],
    #    "metadata": {
    #           # "material_id": material_id,
    #            "path_type": path_type,
    #            "description": f"Band structure plot for material {material_id} using {path_type} path",
    #            "width": int(fig_width),
    #            "height": int(fig_height)
    #        }
    #}


@mcp.tool()
async def get_electronic_dos_by_id(
    material_id: str = Field(
        ..., 
        description="Materials Project ID (e.g. 'mp-149'). Must be a valid MP ID."
    ),
) -> str:
    """
    Retrieve the electronic density of states (DOS) data for a given material.
    
    This function fetches the electronic density of states data from the Materials Project
    for the specified material. The DOS data includes information about the
    electronic states available to electrons in the material.
    
    Args:
        material_id: The Materials Project ID of the material (e.g. 'mp-149')
        
    Returns:
        str: A string containing the density of states information
        
    Example:
        >>> get_electronic_dos_by_id('mp-149')
        Returns the electronic density of states data for silicon
    """   
    logger.info(f"Fetching electronic density of states for {material_id}...")
    with _get_mp_rester() as mpr:
        dos = mpr.get_dos_by_material_id(material_id)

    if not dos:
        return f"No density of states found for {material_id}."
    
    return f"Electronic density of states for {material_id}: {dos}"

#phonons
@mcp.tool()
async def get_phonon_bandstructure(
    material_id: str = Field(
        ..., 
        description="Materials Project ID (e.g. 'mp-149'). Must be a valid MP ID."
    ),
) -> str:
    """
    Retrieve the phonon band structure for a given material.
    
    This function fetches the phonon band structure data from the Materials Project
    for the specified material. The phonon band structure includes information about
    the vibrational modes and frequencies of the material.
    
    Args:
        material_id: The Materials Project ID of the material (e.g. 'mp-149')
        
    Returns:
        A plot of the phonon band structure 
    """

    logger.info(f"Fetching phonon band structure for {material_id}...")
    with _get_mp_rester() as mpr:
        bs = mpr.get_phonon_bandstructure_by_material_id(material_id)

    if not isinstance(bs, PhononBandStructureSymmLine):
        return "Cannot plot phonon band structure. Only line-mode paths are plottable."    

    plotter = PhononBSPlotter(bs)
    fig = plotter.get_plot()
    
    plt.title(f"Phonon Band Structure for {material_id}")
    plt.ylabel("Frequency (THz)")
    plt.tight_layout()
    # Save the figure to a buffer
    buffer = io.BytesIO()
    fig.savefig(buffer, format='png', dpi=300, bbox_inches='tight')
    plt.close(fig)

    # Convert the buffer to base64
    phonon_image_data = buffer.getvalue()
    image_base64 = base64.b64encode(phonon_image_data).decode('ascii')
    
    # figure dimensions
    fig_width = fig.get_figwidth() * fig.dpi
    fig_height = fig.get_figheight() * fig.dpi

    
    return {
        "success": True,
        "material_id": material_id,
        "image_base64": image_base64,
        "metadata": {
            "path_type": "phonon",
            "description": f"Phonon band structure plot for material {material_id}",
            "width": int(fig_width),
            "height": int(fig_height)
        }
    }
 



@mcp.tool()
async def get_phonon_dos_by_id(
    material_id: str = Field(
        ..., 
        description="Materials Project ID (e.g. 'mp-149'). Must be a valid MP ID."
    ),
) -> str:
    """
    Retrieve the phonon density of states (DOS) data for a given material.
    
    This function fetches the phonon density of states data from the Materials Project
    for the specified material. The DOS data includes information about the
    vibrational modes and frequencies of the material.
    """
    logger.info(f"Fetching phonon density of states for {material_id}...")
    with _get_mp_rester() as mpr:
        dos = mpr.get_phonon_dos_by_material_id(material_id)

    if not dos:
        return f"No density of states found for {material_id}."

    return f"Phonon density of states for {material_id}: {dos}"
    

@mcp.tool()
async def get_ion_reference_data_for_chemsys(
    chemsys: Optional[Union[List, str]] = Field(
        ..., 
        description="Chemical system string comprising element symbols separated by dashes, e.g., 'Li-Fe-O' or List of element symbols, e.g., ['Li', 'Fe', 'O']"
    )
) -> str: 
    """
    Downloads aqueouse  ion reference data used in the contruction Pourbaix 
    The data returned from this method can be passed to get_ion_entries(). 

    Args:
        chemsys (str | list):  Chemical system string comprising element
                symbols separated by dashes, e.g., "Li-Fe-O" or List of element
                symbols, e.g., ["Li", "Fe", "O"].

    Returns:
            str: markdown format of the reference data for ions 
    """

    logger.info("Fetch reference data for ion by Chemsys")
    mpr_rester = _get_mp_rester()

    with mpr_rester as mpr: 
        ion_reference_data = mpr.get_ion_reference_data_for_chemsys(chemsys=chemsys)

    if not ion_reference_data: 
        logger.info(f"data not found for {chemsys}")
        return f"No ion reference data for {chemsys}"


    ion_data = f"Ion Reference Data for Chemical System: {chemsys}\n\n"

    for idx, ion in enumerate(ion_reference_data, 1): 
        identifier = ion.get("identifier", "Unknown")
        formula = ion.get("formula", "Unknown")
        data = ion.get("data", {})

        # get the properties for idx 
        charge_info = data.get("charge", {})
        charge_value = charge_info.get('value', 0)
        charge_display = charge_info.get('display', str(charge_value))
            
        delta_gf_info = data.get('ΔGᶠ', {})
        delta_gf_value = delta_gf_info.get('value', 'N/A')
        delta_gf_display = delta_gf_info.get('display', f'{delta_gf_value} kJ/mol' if delta_gf_value != 'N/A' else 'N/A')

        maj_elements = data.get('MajElements', 'Unknown')
        ref_solid = data.get('RefSolid', 'Unknown')

        ref_solid_info = data.get('ΔGᶠRefSolid', {})
        ref_solid_value = ref_solid_info.get('value', 'N/A')
        ref_solid_display = ref_solid_info.get('display', f'{ref_solid_value} kJ/mol' if ref_solid_value != 'N/A' else 'N/A')

        reference = data.get('reference', 'No reference provided')

        ion_data +=  f"""## {idx}. {identifier}

            | Property | Value |
            |----------|--------|
            | *Formula* | {formula} |
            | *Charge* | {charge_display} |
            | *Formation Energy (ΔGᶠ)* | {delta_gf_display} |
            | *Major Elements* | {maj_elements} |
            | *Reference Solid* | {ref_solid} |
            | *Ref. Solid ΔGᶠ* | {ref_solid_display} |

            *Reference:* {reference}
        """

    return ion_data


@mcp.tool()
async def get_cohesive_energy(
        material_ids: List[str] = Field(
            ...,
            description="List of Material IDs to compute cohesive energies"
        ),
        normalization: str = Field(
            default="atom",
            description="The normalization to use, whether to normalize cohesive energy by number of atoms (deflaut) or by number of formula units  "
        )
) -> str:
    """
    Obtain the cohesive energy of the structure(s) corresponding to single or multiple material IDs

    Args:
        material_ids: List to material IDs to compute their cohesive energy
        normalization: Whether to normalize cohesive energy using number of atoms or number of formula

    Returns:
        str: The Markdown of  cohesive energies (in eV/atom or eV/formula unit) for
            each material, indexed by Material IDs .

    """
    logger.info("Getting cohesive energy for material IDs")

    with _get_mp_rester() as mpr:
        cohesive_energies = mpr.get_cohesive_energy(material_ids=material_ids, normalization=normalization)

    if not cohesive_energies:
        logger.info(f"No cohesive energy was retrived for {material_ids}")
        return f"No cohesive energies found for these Material IDs: {material_ids}"


    energies = f"## Cohesive Energies \n"
    for identifier, energy in cohesive_energies.items():
        unit = "eV/atom" if normalization == "atom" else "eV/formula unit"
        energies += f"-- **{identifier}** : {energy} {unit}\n"

    return energies


@mcp.tool()
async def get_atom_reference_data(
        funcs: tuple[str, ...] = Field(
            default=("PBE",),
            description="list of functionals to retrieve data for "
        )
) -> str:
    """
    Retrieve reference energies of isolated neutral atoms. this energies can be used to calculate formations energies of compounds,
    Write the meaning of these funcs eg thier full names
    Args:
        funcs ([str] or None ) : list of functionals to retrieve data for.
    Returns:
        str : Markdown containing isolated atom energies 
    """
    logger.info("Getting Atom Reference Data")
    with _get_mp_rester() as mpr:
        atom_data = mpr.get_atom_reference_data(funcs=funcs)

    if not atom_data:
        return f"No atom data retrieved for functionals {funcs}"

    atom_references = "| Element | Reference Energy (eV/atom) |\n"

    for element, energy in atom_data.items():
        atom_references += f"| **{element}** | {energy} | \n"

    return atom_references



@mcp.tool()
async def get_magnetic_data_by_id(
        material_ids: list[str] = Field(
            ...,
            description="Material ID of the material"
        ),
) -> str: 
    """
    Get magnetic data using material ID. The materials api provides computed
    magnetic propertics from Density Functional Theory (DFT) calculations. This includes
    1. Magnetic ordering
    2. Total Magnetization
    3. Site-projected Magnetic Moments
    4. Spin-polarized electronic structures

    Args:
        material_id: Material ID of the material e.g., mp-20664, which is Mn2Sb
    
    Returns:
        (str): returns a markdown string containing the magnetic data for the material.
    """
    logger.info(f"Getting magnetic data for material{material_ids}")
    with _get_mp_rester() as mpr:
        magnetic_data = mpr.magnetism.search(material_ids=material_ids)


    if not magnetic_data:
        logger.info(f"Not data collected for {material_ids}")
        return f"No magnetic data found for material {material_ids}"
    
    data_md  = f"|##      Magnetic Data for Material IDs    |\n\n"
    for idx, model in enumerate(magnetic_data): 
        data_md += f"idx : {idx}"
        data = model.model_dump()
        for key, value in data.items(): 
            data_md += f"| **{key}       :         {value}   |\n\n"
            
    
    return data_md
    


@mcp.tool()
async def get_charge_density_by_id(
        material_ids: str = Field(
            ...,
            description="Material ID of the material"
        )
):
    """
    Get charge density data for a given materials project ID
    Args:
        material_id: Material Project ID

    Returns:
        str :

    """
    logging.info(f"Getting charge density of material {material_ids}")
    with _get_mp_rester() as mpr:
        charge_density = mpr.get_charge_density_from_material_id(material_id=material_ids)
        logger.info(f"Charge density data retrieved for {material_ids}")
        

    if not charge_density:
        return f"No data found for material {charge_density}"

    density_data = f"""
            ## Material ID: {material_ids}
        
            ### Structure Summary:
            {charge_density.structure}
        
            ### Charge Density (Total):
            {charge_density.data["total"]}
            
            ### Is charge Density Polarized : 
            {charge_density.is_spin_polarized}
            
        """

    return density_data


@mcp.tool()
async def get_dielectric_data_by_id(
        material_id: str = Field(
            ..., 
            description="Material ID of the material"
    )
) -> str: 
    """
    Gets the dielectric data for a given material. Dielectric is a 
    material the can be polarized by an applied electric field. 
    The mathematical description of the dielectric effect is a tensor 
    constant of proportionality that relates an externally applied electric 
    field to the field within the material
    
    Args: 
        material_id (str): Material ID for the material

    Returns: 
        str: markdown of the dielectric data


    """
    logger.info(f"Getting Dielectric data for material: {material_id}")
    with _get_mp_rester() as mpr: 
        dielectric_data = mpr.materials.dielectric.search(material_id)
    
    if not dielectric_data: 
        logger.info(f"No data found for material {material_id}")
        return f"No data for the material: {material_id}"

    data_md  = f"|##    Dielectric Data  for Material IDs    |\n\n"
    for idx, model in enumerate(dielectric_data): 
        data_md += f"idx : {idx}"
        data = model.model_dump()
        for key, value in data.items(): 
            data_md += f"| **{key}    :    {value}   |\n\n"
            
    return data_md
    


@mcp.tool()
async def get_diffraction_patterns(
    material_id : str = Field(
        ..., 
        description="Material ID of the material "
    )
) -> str: 
    """
    Gets diffraction patterns of a material given its ID. 
    Diffraction occurs when waves (electrons, x-rays, neutrons)
    scattering from obstructions act as a secondary sources of propagations

    Args: 
        material id (str): the material id of the material to get the diffracton pattern 


    Return: 
        str: markdown of the patterns 
    
    """
    logger.info(f"Getting the Diffraction Pattern of element: {material_id}")
  
    with _get_mp_rester() as mpr: 
        # first retrieve the relevant structure 
        structure = mpr.get_structure_by_material_id(material_id)
    try: 
        sga = SpacegroupAnalyzer(structure=structure)
        conventional_structure  = sga.get_conventional_standard_structure()
        calculator = XRDCalculator(wavelength="CuKa")
        pattern = calculator.get_pattern(conventional_structure)
        return str(pattern)
    except: 
        logging.error("Error occurred when function get_diffraction_patterns ")
        return f"No diffraction pattern retrieved for material : {material_id}"
    


    
@mcp.tool()
async def get_xRay_absorption_spectra(
    material_ids: List[str] = Field(
        ..., 
        description="Material ID of the material"
    )
) -> str:
    """
    Obtain X-ray Absorption Spectra using single or multiple IDs,
    following the methodology as discussed by Mathew et al and Chen et al.

    Args: 
        material_ids (List[str]) : material_ids of the elements
    
    Return: 
        str: 
    
    """
    logging.info("")
    with _get_mp_rester() as mpr: 
        xas_doc = mpr.materials.xas.search(material_ids=material_ids)

    if not xas_doc: 
        logging.info(f"No data retrieve for material(s) : {material_ids}")
        return f"No data retrieve for material(s) : {material_ids}"

    data_md = f"|##  X-ray absorption spectra for Material IDs    |\n\n"
    for idx, model in enumerate(xas_doc):
        data_md += f"idx : {idx}"
        data = model.model_dump()
        for key, value in data.items():
            data_md += f"| **{key}  :  {value}   |\n\n"

    return data_md


@mcp.tool()
async def get_elastic_constants(
    material_ids: List[str] = Field(
        ..., 
        description="Material ID of the material"
    )
):
    """
    Obtain Elastic constants given material IDs.
    Elasticity describes a material's ability to resist deformations
    (i.e. size and shape) when subjected to external forces.

    :param material_ids :   material ID(s) of the elements

    :return:
        str: markdown of the elastic constants

    """
    logging.info(f"Getting Elastic Constant for material(s): {material_ids}")
    with _get_mp_rester() as mpr:
        elasticity_doc = mpr.materials.elasticity.search(material_ids=material_ids)

    if not elasticity_doc:
        return f"No Elasticity data retrieved for material: {material_ids}"

    data_md = f"|##     Elastic Constants   |\n\n"
    for idx, model in enumerate(elasticity_doc):
        data_md += f"idx : {idx}"
        data = model.model_dump()
        for key, value in data.items():
            data_md += f"| **{key}  :  {value}   |\n\n"

    return data_md



 

@mcp.tool()
async def get_suggested_substrates(
    material_id: str = Field(
        ..., 
        description="Material ID of the material"
    )
) -> str: 
    """
    Obtains Suggested substrates for a film material. 
    It helps to find suitable substrate materials for thin films 
    
    Args: 
        material_id (str): material ID of the material 
    
    Returns: 
        str: markdown of the data 
    
    """
    logging.info(f"Getting suggested substrates for the material : {material_id}")
    with _get_mp_rester() as mpr: 
        substrates_doc = mpr.materials.substrates.search(film_id=material_id)

    if not substrates_doc: 
        return f"No substrates gotten for material: {material_id}"

    sub_md = f""
    for idx, data in enumerate(substrates_doc):
        sub_md += f"## Substrate {idx + 1}\n\n"
        # Create a detailed view for each substrate
        sub_md += f"- **Index**: {idx}\n"
        sub_md += f"- **Substrate Formula**: {getattr(data, 'sub_form', 'N/A')}\n"
        sub_md += f"- **Substrate ID**: {getattr(data, 'sub_id', 'N/A')}\n"
        sub_md += f"- **Film Orientation**: {getattr(data, 'film_orient', 'N/A')}\n"
        sub_md += f"- **Area**: {getattr(data, 'area', 'N/A')}\n"
        sub_md += f"- **Energy**: {getattr(data, 'energy', 'N/A')}\n"
        sub_md += f"- **Film ID**: {getattr(data, 'film_id', 'N/A')}\n"
        sub_md += f"- **Orientation**: {getattr(data, 'orient', 'N/A')}\n\n"
        sub_md += "---\n\n"
    
    return sub_md



@mcp.tool()
async def get_thermo_stability(
    material_ids: List[str] = Field(
        ..., 
        description="Materials IDs of the material"
    ), 
    thermo_types: List[str] = Field(
        default=["GGA_GGA+U_R2SCAN"], 
        description=""
    )
) -> str: 
    """
    Obtains thermodynamic stability data for a material

    Args: 
        material_ids (List[str]) : A list of the material ID(s) eg. ["mp-861883"]
        thermo_types (List[str]) : 

    Returns: 
        str: Markdown of the thermodynamic stability data 
    
    """
    logging.info(f"Getting thermodynamic stability for material(s):{material_ids}")
    with _get_mp_rester() as mpr: 
        thermo_docs = mpr.materials.thermo.search(
            material_ids=material_ids, 
            thermo_types=thermo_types
        )

    if not thermo_docs: 
        logging.info("No thermodynamic stability data retrieved for ")
        return f"No thermodynamic stability data retrieved for materials: {material_ids}"
    
    thermo_md = f"Thermodynamic Stability for: {material_ids}"
    for idx, data in enumerate(thermo_docs):
        thermo_md += f"\n--- Material {idx + 1} ---\n"
       
        energy_above_hull = getattr(data, "energy_above_hull", "Not available")
        thermo_md += f"| ** | energy_above_hull : {energy_above_hull} | \n"
      
        formation_energy = getattr(data, "formation_energy_per_atom", "Not available")
        thermo_md += f"| ** | formation_energy_per_atom : {formation_energy} | \n"
        
        thermo_type = getattr(data, "thermo_type", "Not available")
        thermo_md += f"| ** | thermo_type : {thermo_type} | \n"
        
        
        is_stable = getattr(data, "is_stable", False)
        thermo_md += f"| ** | is_stable : {is_stable} | \n"
    
        formula_pretty = getattr(data, 'formula_pretty', 'Not available')
        thermo_md += f"| ** | formula : {formula_pretty} | \n"

    return thermo_md


@mcp.tool()
async def get_surface_properties(
        material_id: str = Field(
            ...,
            description="Material ID of the material"
        ),
) -> str:
    """
    Gets Surface properties data for materials as discussed by
    the methodology by Tran et. al.

    :param
        material_id: Material ID for the material
        response_limit (int) : Response limit for each call
    :return:
        Markdown of the surface data

    """
    logging.info(f"Getting surface data for material: {material_id}")
    with _get_mp_rester() as mpr:
        surface_docs = mpr.materials.surface_properties.search(material_id)

    if not surface_docs:
        logging.info(f"No surface data retrieved for material: {material_id}")
        return f"No surface data retrieved for material: {material_id}"

    surface_md = f"# Surface Properties for material: {material_id}\n\n"

    # surface_docs is a list of surface documents
    for idx, surface_doc in enumerate(surface_docs):
        # Access the surfaces from each document
        if hasattr(surface_doc, 'surfaces') and surface_doc.surfaces:
            for surface_idx, surface in enumerate(surface_doc.surfaces):
                miller_index = surface.miller_index
                miller_str = f"({miller_index[0]}{miller_index[1]}{miller_index[2]})"

                surface_md += f"## Surface {idx + 1}.{surface_idx + 1}: {miller_str}\n\n"
                surface_md += f"- **Miller Index:** {miller_index}\n"
                surface_md += f"- **Surface Energy:** {surface.surface_energy:.4f} J/m²\n"
                surface_md += f"- **Surface Energy (eV/Ų):** {getattr(surface, 'surface_energy_EV_PER_ANG2', 'N/A')}\n"
                surface_md += f"- **Work Function:** {getattr(surface, 'work_function', 'N/A')} eV\n"
                surface_md += f"- **Fermi Energy:** {getattr(surface, 'efermi', 'N/A')} eV\n"
                surface_md += f"- **Area Fraction:** {getattr(surface, 'area_fraction', 'N/A')}\n"
                surface_md += f"- **Is Reconstructed:** {'Yes' if getattr(surface, 'is_reconstructed', False) else 'No'}\n"
                surface_md += f"- **Has Wulff Shape:** {'Yes' if getattr(surface, 'has_wulff', False) else 'No'}\n\n"

        # Add material properties from the document
        surface_md += f"### Material Properties\n\n"
        surface_md += f"- **Material ID:** {getattr(surface_doc, 'material_id', 'N/A')}\n"
        surface_md += f"- **Formula:** {getattr(surface_doc, 'formula_pretty', 'N/A')}\n"
        surface_md += f"- **Crystal System:** {getattr(surface_doc, 'crystal_system', 'N/A')}\n"
        surface_md += f"- **Space Group:** {getattr(surface_doc, 'space_group', 'N/A')}\n\n"

    return surface_md


@mcp.tool()
async def get_grain_boundaries(
        material_id: str = Field(
            ...,
            description="Material ID of the material"
        )
):

    """
    Get Computed Grain Boundaries for a material.

    :param material_id (str): Material ID of the material

    :return:
        Markdown of the grain boundaries data
    """
    logger.info(f"Getting Grain Boundaries for material: {material_id}")
    with _get_mp_rester() as mpr:
        grain_boundary_docs = mpr.materials.grain_boundaries.search(material_id)

    if not grain_boundary_docs:
        logger.info(f"No Grain Boundaries data for material: {material_id}")
        return f"No Grain Boundaries data for material: {material_id}"

    grain_md = f"# Grain Boundaries for material: {material_id} \n\n"
    for idx, data in enumerate(grain_boundary_docs):
        grain_md += f"- **Initial Structure : ** {getattr(data, "initial_structure", "N/A")}\n\n"
        grain_md += f"- ** Final Structure : ** {getattr(data, "final_structure", "N/A")} \n\n"
    return grain_md


@mcp.tool()
async def get_insertion_electrodes(
        material_id: str = Field(
            ...,
            description="Material ID of the material"
        )
) -> str:
    """
    Get Insertion Electrodes data for a material.


    :param 
        material_id (str): Material ID of the material
    :return
        str: Markdown of the Insertion Electrodes data

    """
    logger.info(f"Getting Insertion Electrodes data for material: {material_id}")
    with _get_mp_rester() as mpr:
        electrodes_docs = mpr.materials.insertion_electrodes.search(material_id)

    
    if not electrodes_docs:
        logger.info(f"No Insertion Electrodes data for material: {material_id}")
        return f"No Insertion Electrodes data for material: {material_id}"
    
    electrodes_md = f"# Insertion Electrodes for material: {material_id}\n\n"
    for idx, data in enumerate(electrodes_docs):
        electrodes_md += f"## Electrode {idx + 1}\n\n"
        electrodes_md += f"- **Battery Type:** {getattr(data, 'battery_type', 'N/A')}\n"
        electrodes_md += f"- **Battery ID:** {getattr(data, 'battery_id', 'N/A')}\n"
        electrodes_md += f"- **Battery Formula:** {getattr(data, 'battery_formula', 'N/A')}\n"
        electrodes_md += f"- **Working Ion:** {getattr(data, 'working_ion', 'N/A')}\n"
        electrodes_md += f"- **Number of Steps:** {getattr(data, 'num_steps', 'N/A')}\n"
        electrodes_md += f"- **Max Voltage Step:** {getattr(data, 'max_voltage_step', 'N/A')}\n"
        electrodes_md += f"- **Last Updated:** {getattr(data, 'last_updated', 'N/A')}\n"
        electrodes_md += f"- **Framework:** {getattr(data, 'framework', 'N/A')}\n"
        electrodes_md += f"- **Framework Formula:** {getattr(data, 'framework_formula', 'N/A')}\n"
        electrodes_md += f"- **Elements:** {getattr(data, 'elements', 'N/A')}\n"
        electrodes_md += f"- **Number of Elements:** {getattr(data, 'nelements', 'N/A')}\n"
        electrodes_md += f"- **Chemical System:** {getattr(data, 'chemsys', 'N/A')}\n"
        electrodes_md += f"- **Formula Anonymous:** {getattr(data, 'formula_anonymous', 'N/A')}\n"
        electrodes_md += f"- **Warnings:** {getattr(data, 'warnings', 'N/A')}\n"
        electrodes_md += f"- **Formula Charge:** {getattr(data, 'formula_charge', 'N/A')}\n"
        electrodes_md += f"- **Formula Discharge:** {getattr(data, 'formula_discharge', 'N/A')}\n"
        electrodes_md += f"- **Max Delta Volume:** {getattr(data, 'max_delta_volume', 'N/A')}\n"
        electrodes_md += f"- **Average Voltage:** {getattr(data, 'average_voltage', 'N/A')}\n"
        electrodes_md += f"- **Capacity Gravimetric:** {getattr(data, 'capacity_grav', 'N/A')}\n"
        electrodes_md += f"- **Capacity Volumetric:** {getattr(data, 'capacity_vol', 'N/A')}\n"
        electrodes_md += f"- **Energy Gravimetric:** {getattr(data, 'energy_grav', 'N/A')}\n"
        electrodes_md += f"- **Energy Volumetric:** {getattr(data, 'energy_vol', 'N/A')}\n"
        electrodes_md += f"- **Fraction A Charge:** {getattr(data, 'fracA_charge', 'N/A')}\n"
        electrodes_md += f"- **Fraction A Discharge:** {getattr(data, 'fracA_discharge', 'N/A')}\n"
        electrodes_md += f"- **Stability Charge:** {getattr(data, 'stability_charge', 'N/A')}\n"
        electrodes_md += f"- **Stability Discharge:** {getattr(data, 'stability_discharge', 'N/A')}\n"
        electrodes_md += f"- **ID Charge:** {getattr(data, 'id_charge', 'N/A')}\n"
        electrodes_md += f"- **ID Discharge:** {getattr(data, 'id_discharge', 'N/A')}\n"
        electrodes_md += f"- **Host Structure:** {getattr(data, 'host_structure', 'N/A')}\n"
        
        # Add adjacent pairs information
        adj_pairs = getattr(data, 'adj_pairs', [])
        if adj_pairs:
            electrodes_md += f"\n### Adjacent Pairs:\n"
            for pair in adj_pairs:
                     # Use getattr instead of .get for pydantic/model objects
                electrodes_md += f"- **Formula Charge:** {getattr(pair, 'formula_charge', 'N/A')}\n"
                electrodes_md += f"- **Formula Discharge:** {getattr(pair, 'formula_discharge', 'N/A')}\n"
                electrodes_md += f"- **Max Delta Volume:** {getattr(pair, 'max_delta_volume', 'N/A')}\n"
                electrodes_md += f"- **Average Voltage:** {getattr(pair, 'average_voltage', 'N/A')}\n"
                electrodes_md += f"- **Capacity Gravimetric:** {getattr(pair, 'capacity_grav', 'N/A')}\n"
                electrodes_md += f"- **Capacity Volumetric:** {getattr(pair, 'capacity_vol', 'N/A')}\n"
                electrodes_md += f"- **Energy Gravimetric:** {getattr(pair, 'energy_grav', 'N/A')}\n"
                electrodes_md += f"- **Energy Volumetric:** {getattr(pair, 'energy_vol', 'N/A')}\n"
                electrodes_md += f"- **Fraction A Charge:** {getattr(pair, 'fracA_charge', 'N/A')}\n"
                electrodes_md += f"- **Fraction A Discharge:** {getattr(pair, 'fracA_discharge', 'N/A')}\n"
                electrodes_md += f"- **Stability Charge:** {getattr(pair, 'stability_charge', 'N/A')}\n"
                electrodes_md += f"- **Stability Discharge:** {getattr(pair, 'stability_discharge', 'N/A')}\n"
                electrodes_md += f"- **ID Charge:** {getattr(pair, 'id_charge', 'N/A')}\n"
                electrodes_md += f"- **ID Discharge:** {getattr(pair, 'id_discharge', 'N/A')}\n"
        
    return electrodes_md



@mcp.tool()
async def get_oxidation_states(
    material_id : str = Field(
        ...,
        description="Material ID for the material"
    ),
    formula: Optional[str] = Field(
        default=None, 
        description="Query by formula including anonymized formula or by including wild cards"
    )
) -> str:
    """
    Get oxidation states for a given material ID or formula.
    
    This function retrieves the oxidation states of elements in a material
    from the Materials Project database. It can be queried by material ID or
    by formula, including anonymized formulas or wildcards.
    
    Args:
        material_id: The Materials Project ID of the material (e.g. 'mp-149')
        formula: Optional formula to query oxidation states (e.g. 'LiFeO2')
        
    Returns:
        str: A formatted markdown string containing the oxidation states information
        
    Example:
        >>> get_oxidation_states('mp-149')
        Returns oxidation states for silicon (mp-149)
    """
    logger.info(f"Fetching oxidation states for {material_id} with formula {formula}...")
    with _get_mp_rester() as mpr:
        oxidation_states = mpr.materials.oxidation_states.search(
            material_ids=material_id,
            formula=formula
        )

    if not oxidation_states:
        return f"No oxidation states found for {material_id}."

    oxidation_md = f"## Oxidation States for {material_id}\n\n"
    for idx, data in enumerate(oxidation_states):
        #oxidation_md = f"## Oxidation States for {material_id}\n\n"
        #oxidation_md += f"- **Material ID**: {material_id}\n"
        oxidation_md += f"- **Formula**: {getattr(data, "formula_pretty", "N/A")}\n\n"
        oxidation_md += f"- **formula_anonymous**: {getattr(data, "formula_anonymous", "N/A")}\n\n"
        oxidation_md += f"- **density** : {getattr(data, "density", "N/A")}\n\n"
        oxidation_md += f"- **volume**: {getattr(data, "volume", "N/A")}\n\n"
        oxidation_md += f"- **symmetry**: {getattr(data, "symmetry", "N/A")}\n\n"
        oxidation_md += f"- **nelements**: {getattr(data, "nelements", "N/A")}\n\n"
        oxidation_md += f"- **density_atomic**: {getattr(data, "density_atomic", "N/A")}\n\n"
        oxidation_md += f"- **property_name: {getattr(data, "property_name", "N/A")}\n\n"
        oxidation_md += f"- **structure**: {getattr(data, "structure", "N/A")}\n\n"
        oxidation_md += f"- **possible_species**: {getattr(data, "possible_species", "N/A")}\n\n"
        oxidation_md += f"- **possible_valances**: {getattr(data, "possible_valences", "N/A")}\n\n"
        oxidation_md += f"- **method**: {getattr(data, "method", "N/A")}\n\n"

    return oxidation_md

@mcp.tool()
async def construct_wulff_shape(
    material_id: str = Field(
        ..., 
        description="material ID of the material "
    )
): 
    """
    Constructs a Wulff shape for a material.
    
    Args:
        material_id (str): Materials Project material_id, e.g. 'mp-123'.
    
    Returns: 
        object: image of the wulff shape 
        
    """
    logging.info(f"Getting Wulff shape for material: {material_id}")
    with _get_mp_rester() as mpr: 
        surface_data = mpr.surface_properties.search(material_id)


    if not surface_data: 
        return f"No surface data collected for wulff shape"
    
    try: 
        surface_energies = []
        miller_indices = []

        for surface in surface_data[0].surfaces: 
            miller_indices.append(surface.miller_index)
            surface_energies.append(surface.surface_energy)
        
        structure = mpr.get_structure_by_material_id(material_id=[material_id])
        
        wulff_shape = WulffShape(
            lattice=structure.lattice, 
            miller_list=miller_indices, 
            e_surf_list=surface_energies
        )

        # plot the shape 
        import io 
        import base64
        fig = wulff_shape.get_plot()
        #fig.suptitle(f"Wulff Shape\nVolume: {wulff_shape.volume:.3f} Ų", fontsize=14)
        buffer = io.BytesIO()
        fig.savefig(buffer, format='png', dpi=300, bbox_inches='tight')
        buffer.seek(0)
        image_base64 = base64.b64encode(buffer.read()).decode()
        # get the image
        #plt.close(fig) 
        return {
            "success": True,
            "material_id": material_id,
            "volume": round(wulff_shape.volume, 3),
            "surface_count": len(surface_energies),
            "miller_indices": miller_indices,
            "surface_energies": surface_energies,
            "image_base64": image_base64,
            "message": f"Wulff shape constructed for {material_id}"
        }

    except Exception as e: 
        logging.error(f"Error occurred constructing wulff shape: {e}")
        return f"No wulff shape construted for material: {material_id}"



@mcp.resource(uri="materials_docs://{filename}")
async def get_materials_docs(
    filename: str
) -> str: 
    """
    Retrieve docs from the markdown folder
    
    Args:
        filename (str): The name of the file to retrieve from the folder eg. apidocs or docsmaterials
        
    """
    from utils.utility_functions import MarkdownResourceManager, MARKDOWN_FOLDER
    logger.info(f"Retrieving documentation file: {filename}")
    resource_manager = MarkdownResourceManager(MARKDOWN_FOLDER)
    try: 
        resource_manager.load_files()
        if filename not in resource_manager.files:
            logger.error(f"File {filename} not found in the documentation resources.")
            return f"File {filename} not found in the documentation resources."
        file_content = resource_manager.files[filename].content
        print(file_content)
        return file_content
    except Exception as e:
        logger.error(f"Error retrieving documentation file {filename}: {e}")
        return f"Error retrieving documentation file {filename}: {e}"


@mcp.prompt(name="get_electronic_bandstructure")
async def BandStructurePrompt() -> str:
   """Prompt for retrieving electronic band structure data."""
   from utils.prompts_templates import ElectronicBandStructurePrompt
    # I want to return 
   return ElectronicBandStructurePrompt.template


@mcp.tool()
async def get_doi(
    material_id: str = Field(
        ..., 
        description="Material ID of the material"
    )
) -> str: 
    """
    Get DOI  and bibTex reference for a given material ID.
    
    Args:
        material_id (str): The Materials Project ID of the material (e.g. 'mp-149')
    Returns:
        str: A formatted markdown string containing the DOI and bibTex reference
    """
    logger.info("Fetching DOI for material ID {material_id}...")
    with _get_mp_rester() as mpr:
        doi_data = mpr.doi.get_data_by_id(material_id)

    if not doi_data:
        return f"No DOI found for material ID {material_id}."
    
    dos_markdown = f"## DOI Information for {material_id}\n\n"
    dos_markdown += f"- **DOI:** {doi_data.doi} \n\n"
    dos_markdown += f"- **BibTeX:**\n```\n{doi_data.bibtex}\n```\n"
    
    return dos_markdown








if __name__ == "__main__":
    # Initialize and run the server
    mcp.run(transport='stdio')
```