#
tokens: 25169/50000 14/14 files
lines: on (toggle) GitHub
raw markdown copy reset
# 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:
--------------------------------------------------------------------------------

```
1 | 3.12
2 | 
```

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

```
1 | .venv
2 | .env
3 | __pycache__
4 | .idea/*
5 | .vscode/*
6 | .vscode/mcp.json
7 | markdown_folder/
8 | .vscode/
9 | 
```

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

```markdown
  1 | # Materials Project MCP
  2 | 
  3 | A Model Context Protocol (MCP) server for querying the Materials Project database using the mp_api client.
  4 | 
  5 | ## Requirements
  6 | 
  7 | - **Materials Project API Key** - [Get one here](https://materialsproject.org/) (free account required)
  8 | - **Docker Desktop** (must be running)
  9 | - **Python 3.12+** with [uv](https://github.com/astral-sh/uv)
 10 | 
 11 | 
 12 | 
 13 | ### Getting Your Materials Project API Key
 14 | 
 15 | 1. Visit [Materials Project](https://materialsproject.org/)
 16 | 2. Create a free account or log in
 17 | 3. Go to your dashboard
 18 | 4. Navigate to API settings
 19 | 5. Generate or copy your API key
 20 | 6. Keep this key secure - you'll need it for setup
 21 | 
 22 | ## Installation Options
 23 | 
 24 | ### Step 1: Docker (Recommended)
 25 | 
 26 | #### Using Docker Run
 27 | 1. **Install Docker Desktop:**
 28 |    - Download from [docker.com](https://www.docker.com/products/docker-desktop/)
 29 |    - Install and **make sure Docker Desktop is running**
 30 | 
 31 | 2. **Pull the Docker image:**
 32 |    ```bash
 33 |    docker pull benedict2002/materials-project-mcp
 34 |    ```
 35 | 
 36 | 3. **Test the installation:**
 37 |    ```bash
 38 |    docker run --rm -i -e MP_API_KEY="your-api-key" benedict2002/materials-project-mcp
 39 |    ```
 40 | 
 41 | #### Using Docker Compose (Easiest)
 42 | 1. **Install Docker Desktop and make sure it's running**
 43 | 
 44 | 2. **Clone the repository:**
 45 |    ```bash
 46 |    git clone <repository-url>
 47 |    cd materials-project-mcp
 48 |    ```
 49 | 
 50 | 3. **Create a `.env` file:**
 51 |    ```bash
 52 |    echo "MP_API_KEY=your-materials-project-api-key" > .env
 53 |    ```
 54 | 
 55 | 4. **Test the setup:**
 56 |    ```bash
 57 |    docker-compose up
 58 |    ```
 59 | 
 60 | 5. **For background running:**
 61 |    ```bash
 62 |    docker-compose up -d
 63 |    ```
 64 | 
 65 | 6. **Stop the service:**
 66 |    ```bash
 67 |    docker-compose down
 68 |    ```
 69 | 
 70 | ### Step 1 : Local Python Installation
 71 | 
 72 | 1. **Install uv (if not already installed):**
 73 |    ```bash
 74 |    curl -Ls https://astral.sh/uv/install.sh | sh
 75 |    ```
 76 | 
 77 | 2. **Clone the repository:**
 78 |    ```bash
 79 |    git clone <repository-url>
 80 |    cd materials-project-mcp
 81 |    ```
 82 | 
 83 | 3. **Create and activate virtual environment:**
 84 |    ```bash
 85 |    uv venv
 86 |    source .venv/bin/activate  # Linux/macOS
 87 |    # or
 88 |    .venv\Scripts\activate     # Windows
 89 |    ```
 90 | 
 91 | 4. **Install dependencies:**
 92 |    ```bash
 93 |    uv pip install -r requirements.txt
 94 |    ```
 95 | 
 96 | 5. **Set your API key:**
 97 |    ```bash
 98 |    export MP_API_KEY="your-api-key"  # Linux/macOS
 99 |    # or
100 |    set MP_API_KEY=your-api-key       # Windows
101 |    ```
102 | 
103 | 6. **Test the installation:**
104 |    ```bash
105 |    python server.py
106 |    ```
107 | 
108 | ## Step 2 : Setup with Claude Desktop
109 | 
110 | 1. **Locate your Claude configuration file:**
111 |    - **macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json`
112 |    - **Windows**: `%APPDATA%\Claude\claude_desktop_config.json`
113 | 
114 | 2. **Choose your configuration method:**
115 | 
116 |    **Using Docker Run**
117 |    ```json
118 |    {
119 |      "mcpServers": {
120 |        "Materials Project MCP": {
121 |          "command": "docker",
122 |          "args": [
123 |            "run", "--rm", "-i",
124 |            "-e", "MP_API_KEY=your-materials-project-api-key",
125 |            "benedict2002/materials-project-mcp"
126 |          ]
127 |        }
128 |      }
129 |    }
130 |    ```
131 | 
132 | 3. **Replace `your-materials-project-api-key` with your actual API key**
133 | 
134 | 4. **Ensure Docker Desktop is running**
135 | 
136 | 5. **Restart Claude Desktop**
137 | 
138 | 6. **Verify installation:**
139 |    - Open a new chat in Claude
140 |    - Ask something like "Search for silicon materials in the Materials Project database" or test any of the availabe tools.
141 |    - You should see Materials Project data in the response
142 | 
143 | ## Setup with VS Code Copilot
144 | 
145 | 1. **Open VS Code Settings:**
146 |    - Press `Ctrl+Shift+P` (Windows/Linux) or `Cmd+Shift+P` (macOS)
147 |    - Type "Preferences: Open User Settings (JSON)"
148 |    - Select it to open settings.json
149 | 
150 | 2. **Add MCP configuration:**
151 |    ```json
152 |    {
153 |      "mcp": {
154 |        "inputs": [],
155 |        "servers": {
156 |          "Materials Project MCP": {
157 |            "command": "docker",
158 |            "args": [
159 |              "run", "--rm", "-i",
160 |              "-e", "MP_API_KEY=your-api-key",
161 |              "benedict2002/materials-project-mcp"
162 |            ]
163 |          }
164 |        }
165 |      },
166 |      "chat.mcp.discovery.enabled": true,
167 |      "workbench.secondarySideBar.showLabels": false
168 |    }
169 |    ```
170 | 
171 | 3. **Alternative: Local Python setup for VS Code:**
172 |    ```json
173 |    {
174 |      "mcp": {
175 |        "inputs": [],
176 |        "servers": {
177 |          "Materials Project MCP": {
178 |            "command": "/usr/local/bin/uv",
179 |            "args": [
180 |              "run",
181 |              "--with",
182 |              "mcp[cli],aiohttp,pydantic,mp_api,pymatgen,emmet-core",
183 |              "/path/to/your/server.py"
184 |            ],
185 |            "env": {
186 |              "MP_API_KEY": "your-api-key"
187 |            }
188 |          }
189 |        }
190 |      },
191 |      "chat.mcp.discovery.enabled": true
192 |    }
193 |    ```
194 | 
195 | 4. **Replace placeholders:**
196 |    - `your-api-key` with your Materials Project API key
197 |    - `/path/to/your/server.py` with the actual path to server.py
198 | 
199 | 5. **Ensure Docker Desktop is running** (for Docker configurations)
200 | 
201 | 6. **Restart VS Code**
202 | 
203 | 7. **Test in VS Code:**
204 |    - Open VS Code chat/copilot
205 |    - Ask about materials from the Materials Project
206 |    - The Docker container will start automatically when VS Code makes requests
207 | 
208 | 
209 | ## Testing & Development (developers)
210 | 
211 | ### Testing Your Installation
212 | 
213 | 1. **Test MCP server locally:**
214 |    ```bash
215 |    mcp dev server.py
216 |    ```
217 |    Look for the line "🔗 Open inspector with token pre-filled:" and use that URL
218 | 
219 | ### Development Workflow
220 | 
221 | 1. **Create a feature branch:**
222 |    ```bash
223 |    git checkout -b feature-name
224 |    ```
225 | 
226 | 2. **Make your changes and test:**
227 |    ```bash
228 |    # Local testing with MCP Inspector
229 |    mcp dev server.py
230 |    # Use the inspector URL to test your changes interactively
231 |    
232 |    # Docker testing
233 |    docker build -t materials-project-mcp-local .
234 |    docker run --rm -i -e MP_API_KEY="your-api-key" materials-project-mcp-local
235 |    
236 |    # Docker Compose testing
237 |    docker-compose up --build
238 |    ```
239 | 
240 | 3. **Commit and push:**
241 |    ```bash
242 |    git add .
243 |    git commit -m "Add feature description"
244 |    git push origin feature-name
245 |    ```
246 | 
247 | 4. **Open a pull request**
248 | 
249 | 
250 | ## Available Tools & Features
251 | 
252 | - **search_materials** - Search by elements, band gap range, stability
253 | - **get_structure_by_id** - Get crystal structures and lattice parameters
254 | - **get_electronic_bandstructure** - Plot electronic band structures
255 | - **get_electronic_dos_by_id** - Get electronic density of states
256 | - **get_phonon_bandstructure** - Plot phonon band structures
257 | - **get_phonon_dos_by_id** - Get phonon density of states
258 | - **get_ion_reference_data_for_chemsys** - Download aqueous ion reference data for Pourbaix diagrams
259 | - **get_cohesive_energy** - Calculate cohesive energies
260 | - **get_atom_reference_data** - Retrieve reference energies of isolated neutral atoms
261 | - **get_magnetic_data_by_id** - Magnetic properties and ordering
262 | - **get_charge_density_by_id** - Charge density data
263 | - **get_dielectric_data_by_id** - Dielectric constants and properties
264 | - **get_diffraction_patterns** - X-ray and neutron diffraction
265 | - **get_xRay_absorption_spectra** - XAFS, XANES, EXAFS spectra
266 | - **get_elastic_constants** - Mechanical properties
267 | - **get_suggested_substrates** - Find substrates for thin films
268 | - **get_thermo_stability** - Thermodynamic stability analysis
269 | - **get_surface_properties** - Surface energies, work functions, and Wulff shapes
270 | - **get_grain_boundaries** - Computed grain boundaries for a material
271 | - **get_insertion_electrodes** - Insertion electrode and battery data
272 | - **get_oxidation_states** - Element oxidation states, formula, and structure info
273 | 
274 | ## Troubleshooting
275 | 
276 | ### Common Issues
277 | 
278 | 1. **"Invalid API key" error:**
279 |    - Verify your API key is correct
280 |    - Check that you've set the environment variable properly
281 |    - Ensure your Materials Project account is active
282 | 
283 | 2. **"Docker not found" or "Cannot connect to Docker daemon":**
284 |    - **Make sure Docker Desktop is installed and running**
285 |    - You should see the Docker Desktop icon in your system tray/menu bar
286 |    - Try `docker --version` to verify Docker is accessible
287 |    - On Windows/Mac: Open Docker Desktop application
288 |    - On Linux: Start Docker service with `sudo systemctl start docker`
289 | 
290 | 3. **Container startup issues:**
291 |    - Docker containers start automatically when Claude/VS Code makes requests
292 |    - No need to manually start containers - they're ephemeral (start → run → stop)
293 |    - Each query creates a fresh container instance
294 | 
295 | 4. **Docker Compose issues:**
296 |    - Make sure Docker Compose is installed: `docker-compose --version`
297 |    - Check your `.env` file exists and has the correct API key
298 |    - Verify the docker-compose.yml file is in the correct location
299 |    - Ensure Docker Desktop is running
300 | 
301 | 5. **MCP server not recognized in Claude:**
302 |    - Check your configuration file path
303 |    - Verify JSON syntax is correct
304 |    - Restart Claude Desktop after configuration changes
305 |    - Ensure Docker Desktop is running
306 | 
307 | 
308 | ### Getting Help
309 | 
310 | - **MCP Inspector**: Use `mcp dev server.py` for interactive testing and debugging.
311 | - **GitHub Issues**: [Create an Issue](https://github.com/yourusername/materials-project-mcp/issues) for bug reports, feature requests, or questions.
312 | - **Materials Project API Docs**: [docs.materialsproject.org](https://docs.materialsproject.org/)
313 | - **MCP Documentation**: [modelcontextprotocol.io](https://modelcontextprotocol.io/)
314 | - **Docker Help**: [docs.docker.com](https://docs.docker.com/)
315 | 
316 | ---
317 | ### Authors
318 | 
319 | - Benedict Debrah
320 | - Peniel Fiawornu
321 | 
322 | ### Reference
323 | 
324 | 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
1 | 
```

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

```python
1 | 
```

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

```python
1 | 
```

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

```python
1 | 
```

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

```yaml
1 | services:
2 |   materials-project-mcp:
3 |     image: benedict2002/materials-project-mcp:latest
4 |     container_name: materials-mcp
5 |     environment:
6 |       - MP_API_KEY=${MP_API_KEY}
7 |     restart: unless-stopped
8 |     stdin_open: true
9 |     tty: true
```

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

```dockerfile
 1 | FROM python:3.12-slim
 2 | 
 3 | # Install uv
 4 | COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
 5 | 
 6 | # Copy the application into the container
 7 | COPY . /app
 8 | 
 9 | # Install the application dependencies
10 | WORKDIR /app
11 | RUN uv sync --frozen --no-cache
12 | 
13 | # Run server.py directly
14 | CMD ["/app/.venv/bin/python", "/app/server/server.py"]
```

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

```python
 1 | from pydantic import Field
 2 | from dataclasses import dataclass
 3 | from pathlib import Path
 4 | from datetime import datetime
 5 | from typing import Dict, List
 6 | import os
 7 | 
 8 | 
 9 | 
10 | MARKDOWN_FOLDER = Path(os.path.dirname(__file__)).parent / "markdown_folder"
11 | print(MARKDOWN_FOLDER)
12 | 
13 | @dataclass
14 | class MarkdownFile: 
15 |     name: str 
16 |     path: Path 
17 |     content: str = Field(default_factory=str)
18 | 
19 | 
20 | class MarkdownResourceManager:
21 |     def __init__(self, folder_path: Path):
22 |         self.folder_path = folder_path
23 |         self.files: Dict[str, MarkdownFile] = {}
24 | 
25 | 
26 |     def load_files(self): 
27 |         """"Load all markdown files from the folder path"""
28 | 
29 |         self.files.clear()
30 | 
31 |         if not self.folder_path.exists():
32 |             print(f"Folder {self.folder_path} does not exist.")
33 |             return "Folder does not exist."
34 |         
35 |         for file_path in self.folder_path.glob("*.md"):
36 |             try: 
37 |                 content = file_path.read_text(encoding="utf-8")
38 |                 last_modified = datetime.fromtimestamp(file_path.stat().st_mtime)
39 |                 markdown_file = MarkdownFile(
40 |                     name=file_path.stem,
41 |                     path=file_path,
42 |                     content=content,
43 |                  #   last_modified=last_modified
44 |                 )
45 |                 self.files[file_path.stem] = markdown_file
46 |             except Exception as e:
47 |                 print(f"Error reading file {file_path}: {e}")
48 | 
49 | 
50 | if __name__ == "__main__":
51 |     manager = MarkdownResourceManager(MARKDOWN_FOLDER)
52 |     manager.load_files()
53 |     print(manager.files["docsmaterialsproject"].content)
54 | 
```

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

```python
 1 | from dataclasses import dataclass
 2 | 
 3 | 
 4 | 
 5 | 
 6 | @dataclass
 7 | class ElectronicBandStructurePrompt:
 8 |     """
 9 |     Dataclass to store the prompt template for electronic band structure tool usage.
10 |     Contains the comprehensive template with instructions, parameters, and examples.
11 |     """
12 |     template: str = """
13 |         # Electronic Band Structure Tool Usage Guide
14 | 
15 |         ## Tool Overview
16 |         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.
17 | 
18 |         ## Tool Parameters
19 |         - material_id (required): Materials Project ID (e.g., 'mp-149', 'mp-22526')
20 |         - path_type (optional): K-point path type:
21 |         - 'setyawan_curtarolo' (default) - Standard path for cubic systems
22 |         - 'hinuma' - Standard path for hexagonal systems
23 |         - 'latimer_munro' - Alternative path for cubic systems
24 |         - 'uniform' - Uniform k-point sampling (not recommended for plotting)
25 | 
26 |         ## Expected Output Format
27 |         The tool returns a JSON object with the following structure:
28 |         
29 |         {
30 |         "success": true,
31 |         "material_id": "mp-149",
32 |         "image_base64": "iVBORw0KGgoAAAANSUhEUgAAB...[~500KB-2MB base64 string]",
33 |         "metadata": {
34 |             "path_type": "setyawan_curtarolo",
35 |             "description": "Band structure plot for material mp-149 using setyawan_curtarolo path",
36 |             "width": 1200,
37 |             "height": 800
38 |         }
39 |         }
40 |         
41 | 
42 |         Note: The image_base64 field contains a very long base64 string (typically 500KB-2MB). For brevity, examples show truncated versions.
43 | 
44 |         ## How to Parse and Display the Response
45 | 
46 |         ### Method 1: Extract and Display Base64 Image (Python)
47 |         
48 |         import json
49 |         import base64
50 |         from PIL import Image
51 |         import io
52 |         import matplotlib.pyplot as plt
53 | 
54 |         def display_bandstructure(response):
55 |             # Parse the JSON response
56 |             data = json.loads(response) if isinstance(response, str) else response
57 |             
58 |             if not data.get("success"):
59 |                 print("Error: Tool execution failed")
60 |                 return
61 |             
62 |             # Extract base64 image data (this will be a very long string)
63 |             image_base64 = data["image_base64"]
64 |             material_id = data["material_id"]
65 |             metadata = data["metadata"]
66 |             
67 |             # Decode base64 to image
68 |             image_bytes = base64.b64decode(image_base64)
69 |             image = Image.open(io.BytesIO(image_bytes))
70 |             
71 |             # Display the image
72 |             plt.figure(figsize=(12, 8))
73 |             plt.imshow(image)
74 |             plt.axis('off')
75 |             plt.title(f"Band Structure: {material_id} ({metadata['path_type']})")
76 |             plt.tight_layout()
77 |             plt.show()
78 |             
79 |             print(f"Material ID: {material_id}")
80 |             print(f"Path Type: {metadata['path_type']}")
81 |             print(f"Image Size: {metadata['width']} × {metadata['height']}")
82 |         
83 |         """
```

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

```
  1 | aiohappyeyeballs==2.6.1
  2 | aiohttp==3.11.18
  3 | aioitertools==0.12.0
  4 | aiosignal==1.3.2
  5 | annotated-types==0.7.0
  6 | anyio==4.9.0
  7 | appnope==0.1.4
  8 | arrow==1.3.0
  9 | ase==3.25.0
 10 | asttokens==3.0.0
 11 | atomate==1.1.0
 12 | attrs==25.3.0
 13 | bcrypt==4.3.0
 14 | bibtexparser==1.4.3
 15 | blinker==1.9.0
 16 | boltons==25.0.0
 17 | boto3==1.38.17
 18 | botocore==1.38.17
 19 | bravado==12.0.1
 20 | bravado-core==6.1.1
 21 | brewer2mpl==1.4.1
 22 | cachetools==6.1.0
 23 | castepxbin==0.3.0
 24 | certifi==2025.4.26
 25 | cffi==1.17.1
 26 | charset-normalizer==3.4.2
 27 | click==8.2.0
 28 | colormath==3.0.0
 29 | comm==0.2.2
 30 | contourpy==1.3.2
 31 | cryptography==44.0.3
 32 | custodian==2025.5.12
 33 | cycler==0.12.1
 34 | debugpy==1.8.14
 35 | decorator==5.2.1
 36 | dnspython==2.7.0
 37 | dotenv==0.9.9
 38 | emmet==2018.6.7
 39 | emmet-core==0.84.6
 40 | executing==2.2.0
 41 | fastapi==0.115.13
 42 | filetype==1.2.0
 43 | fireworks==2.0.4
 44 | flask==3.1.1
 45 | flask-paginate==2024.4.12
 46 | flatten-dict==0.4.2
 47 | flexcache==0.3
 48 | flexparser==0.4
 49 | fonttools==4.58.0
 50 | fqdn==1.5.1
 51 | frozenlist==1.6.0
 52 | gunicorn==23.0.0
 53 | h11==0.16.0
 54 | h5py==3.13.0
 55 | httpcore==1.0.9
 56 | httpx==0.28.1
 57 | httpx-sse==0.4.0
 58 | idna==3.10
 59 | imageio==2.37.0
 60 | importlib-resources==6.5.2
 61 | ipykernel==6.29.5
 62 | ipython==9.2.0
 63 | ipython-pygments-lexers==1.1.1
 64 | isoduration==20.11.0
 65 | itsdangerous==2.2.0
 66 | jedi==0.19.2
 67 | jinja2==3.1.6
 68 | jmespath==1.0.1
 69 | joblib==1.5.0
 70 | json2html==1.3.0
 71 | jsonlines==4.0.0
 72 | jsonpointer==3.0.0
 73 | jsonref==1.1.0
 74 | jsonschema==4.23.0
 75 | jsonschema-specifications==2025.4.1
 76 | jupyter-client==8.6.3
 77 | jupyter-core==5.7.2
 78 | kiwisolver==1.4.8
 79 | latexcodec==3.0.0
 80 | lazy-loader==0.4
 81 | maggma==0.71.5
 82 | markdown-it-py==3.0.0
 83 | markupsafe==3.0.2
 84 | matminer==0.9.3
 85 | matplotlib==3.10.3
 86 | matplotlib-inline==0.1.7
 87 | mcp==1.8.1
 88 | mdurl==0.1.2
 89 | mongomock==4.3.0
 90 | monotonic==1.6
 91 | monty==2025.3.3
 92 | mp-api==0.45.5
 93 | mp-pyrho==0.4.5
 94 | mpcontribs-client==5.10.2
 95 | mpmath==1.3.0
 96 | msgpack==1.1.0
 97 | multidict==6.4.3
 98 | narwhals==1.39.1
 99 | nest-asyncio==1.6.0
100 | networkx==3.4.2
101 | numpy==1.26.4
102 | orjson==3.10.18
103 | packaging==25.0
104 | palettable==3.3.3
105 | pandas==2.2.3
106 | paramiko==3.5.1
107 | parso==0.8.4
108 | pexpect==4.9.0
109 | phonopy==2.38.2
110 | pillow==11.2.1
111 | pint==0.24.4
112 | platformdirs==4.3.8
113 | plotly==6.1.0
114 | prettyplotlib==0.1.7
115 | prompt-toolkit==3.0.51
116 | propcache==0.3.1
117 | psutil==7.0.0
118 | ptyprocess==0.7.0
119 | pure-eval==0.2.3
120 | pybtex==0.24.0
121 | pycparser==2.22
122 | pydantic==2.11.4
123 | pydantic-core==2.33.2
124 | pydantic-settings==2.9.1
125 | pydash==8.0.5
126 | pygments==2.19.1
127 | pyisemail==2.0.1
128 | pymatgen==2025.1.9
129 | pymatgen-analysis-defects==2025.1.18
130 | pymatgen-analysis-diffusion==2024.7.15
131 | pymongo==4.10.1
132 | pynacl==1.5.0
133 | pyparsing==3.2.3
134 | python-dateutil==2.9.0.post0
135 | python-dotenv==1.1.0
136 | python-multipart==0.0.20
137 | pytz==2025.2
138 | pyyaml==6.0.2
139 | pyzmq==26.4.0
140 | referencing==0.36.2
141 | requests==2.32.3
142 | requests-futures==1.0.2
143 | rfc3339-validator==0.1.4
144 | rfc3986-validator==0.1.1
145 | rich==14.0.0
146 | rpds-py==0.25.0
147 | ruamel-yaml==0.18.10
148 | ruamel-yaml-clib==0.2.12
149 | s3transfer==0.12.0
150 | scikit-image==0.25.2
151 | scikit-learn==1.6.1
152 | scipy==1.15.3
153 | seaborn==0.13.2
154 | seekpath==2.1.0
155 | semantic-version==2.10.0
156 | sentinels==1.0.0
157 | setuptools==80.7.1
158 | shellingham==1.5.4
159 | simplejson==3.20.1
160 | six==1.17.0
161 | smart-open==7.1.0
162 | sniffio==1.3.1
163 | spglib==2.6.0
164 | sse-starlette==2.3.5
165 | sshtunnel==0.4.0
166 | stack-data==0.6.3
167 | starlette==0.46.2
168 | sumo==2.3.12
169 | swagger-spec-validator==3.0.4
170 | symfc==1.3.4
171 | sympy==1.14.0
172 | tabulate==0.9.0
173 | threadpoolctl==3.6.0
174 | tifffile==2025.5.10
175 | tornado==6.4.2
176 | tqdm==4.67.1
177 | traitlets==5.14.3
178 | typer==0.15.3
179 | types-python-dateutil==2.9.0.20250516
180 | typing-extensions==4.13.2
181 | typing-inspection==0.4.0
182 | tzdata==2025.2
183 | ujson==5.10.0
184 | uncertainties==3.2.3
185 | uri-template==1.3.0
186 | urllib3==2.4.0
187 | uvicorn==0.34.2
188 | wcwidth==0.2.13
189 | webcolors==24.11.1
190 | werkzeug==3.1.3
191 | wrapt==1.17.2
192 | yarl==1.20.0
193 | beautifulsoup4==4.13.4
194 | soupsieve==2.7
195 | 
```

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

```toml
  1 | [project]
  2 | name = "materials-project-mcp"
  3 | version = "0.1.0"
  4 | description = "Add your description here"
  5 | readme = "README.md"
  6 | requires-python = ">=3.12"
  7 | dependencies = [
  8 |     "aiohappyeyeballs==2.6.1",
  9 |     "aiohttp==3.11.18",
 10 |     "aioitertools==0.12.0",
 11 |     "aiosignal==1.3.2",
 12 |     "annotated-types==0.7.0",
 13 |     "anyio==4.9.0",
 14 |     "appnope==0.1.4",
 15 |     "ase==3.25.0",
 16 |     "asttokens==3.0.0",
 17 |     "atomate==1.1.0",
 18 |     "attrs==25.3.0",
 19 |     "bcrypt==4.3.0",
 20 |     "bibtexparser==1.4.3",
 21 |     "blinker==1.9.0",
 22 |     "boto3==1.38.17",
 23 |     "botocore==1.38.17",
 24 |     "brewer2mpl==1.4.1",
 25 |     "castepxbin==0.3.0",
 26 |     "certifi==2025.4.26",
 27 |     "cffi==1.17.1",
 28 |     "charset-normalizer==3.4.2",
 29 |     "click==8.2.0",
 30 |     "colormath==3.0.0",
 31 |     "comm==0.2.2",
 32 |     "contourpy==1.3.2",
 33 |     "cryptography==44.0.3",
 34 |     "custodian==2025.5.12",
 35 |     "cycler==0.12.1",
 36 |     "debugpy==1.8.14",
 37 |     "decorator==5.2.1",
 38 |     "dnspython==2.7.0",
 39 |     "dotenv>=0.9.9",
 40 |     "emmet==2018.6.7",
 41 |     "emmet-core==0.84.6",
 42 |     "executing==2.2.0",
 43 |     "fastapi>=0.115.13",
 44 |     "fireworks==2.0.4",
 45 |     "flask==3.1.1",
 46 |     "flask-paginate==2024.4.12",
 47 |     "fonttools==4.58.0",
 48 |     "frozenlist==1.6.0",
 49 |     "gunicorn==23.0.0",
 50 |     "h11==0.16.0",
 51 |     "h5py==3.13.0",
 52 |     "httpcore==1.0.9",
 53 |     "httpx==0.28.1",
 54 |     "httpx-sse==0.4.0",
 55 |     "idna==3.10",
 56 |     "imageio==2.37.0",
 57 |     "importlib-resources==6.5.2",
 58 |     "ipykernel==6.29.5",
 59 |     "ipython==9.2.0",
 60 |     "ipython-pygments-lexers==1.1.1",
 61 |     "itsdangerous==2.2.0",
 62 |     "jedi==0.19.2",
 63 |     "jinja2==3.1.6",
 64 |     "jmespath==1.0.1",
 65 |     "joblib==1.5.0",
 66 |     "jsonlines==4.0.0",
 67 |     "jsonschema==4.23.0",
 68 |     "jsonschema-specifications==2025.4.1",
 69 |     "jupyter-client==8.6.3",
 70 |     "jupyter-core==5.7.2",
 71 |     "kiwisolver==1.4.8",
 72 |     "latexcodec==3.0.0",
 73 |     "lazy-loader==0.4",
 74 |     "maggma==0.71.5",
 75 |     "markdown-it-py==3.0.0",
 76 |     "markupsafe==3.0.2",
 77 |     "matminer==0.9.3",
 78 |     "matplotlib==3.10.3",
 79 |     "matplotlib-inline==0.1.7",
 80 |     "mcp[cli]==1.8.1",
 81 |     "mdurl==0.1.2",
 82 |     "mongomock==4.3.0",
 83 |     "monty==2025.3.3",
 84 |     "mp-api==0.45.5",
 85 |     "mp-pyrho==0.4.5",
 86 |     "mpcontribs-client>=5.10.2",
 87 |     "mpmath==1.3.0",
 88 |     "msgpack==1.1.0",
 89 |     "multidict==6.4.3",
 90 |     "narwhals==1.39.1",
 91 |     "nest-asyncio==1.6.0",
 92 |     "networkx==3.4.2",
 93 |     "numpy==1.26.4",
 94 |     "orjson==3.10.18",
 95 |     "packaging==25.0",
 96 |     "palettable==3.3.3",
 97 |     "pandas==2.2.3",
 98 |     "paramiko==3.5.1",
 99 |     "parso==0.8.4",
100 |     "pexpect==4.9.0",
101 |     "phonopy==2.38.2",
102 |     "pillow==11.2.1",
103 |     "platformdirs==4.3.8",
104 |     "plotly==6.1.0",
105 |     "prettyplotlib==0.1.7",
106 |     "prompt-toolkit==3.0.51",
107 |     "propcache==0.3.1",
108 |     "psutil==7.0.0",
109 |     "ptyprocess==0.7.0",
110 |     "pure-eval==0.2.3",
111 |     "pybtex==0.24.0",
112 |     "pycparser==2.22",
113 |     "pydantic==2.11.4",
114 |     "pydantic-core==2.33.2",
115 |     "pydantic-settings==2.9.1",
116 |     "pydash==8.0.5",
117 |     "pygments==2.19.1",
118 |     "pymatgen==2025.1.9",
119 |     "pymatgen-analysis-defects==2025.1.18",
120 |     "pymatgen-analysis-diffusion==2024.7.15",
121 |     "pymongo==4.10.1",
122 |     "pynacl==1.5.0",
123 |     "pyparsing==3.2.3",
124 |     "python-dateutil==2.9.0.post0",
125 |     "python-dotenv==1.1.0",
126 |     "python-multipart==0.0.20",
127 |     "pytz==2025.2",
128 |     "pyyaml==6.0.2",
129 |     "pyzmq==26.4.0",
130 |     "referencing==0.36.2",
131 |     "requests==2.32.3",
132 |     "rich==14.0.0",
133 |     "rpds-py==0.25.0",
134 |     "ruamel-yaml==0.18.10",
135 |     "ruamel-yaml-clib==0.2.12",
136 |     "s3transfer==0.12.0",
137 |     "scikit-image==0.25.2",
138 |     "scikit-learn==1.6.1",
139 |     "scipy==1.15.3",
140 |     "seaborn==0.13.2",
141 |     "seekpath==2.1.0",
142 |     "sentinels==1.0.0",
143 |     "setuptools==80.7.1",
144 |     "shellingham==1.5.4",
145 |     "six==1.17.0",
146 |     "smart-open==7.1.0",
147 |     "sniffio==1.3.1",
148 |     "spglib==2.6.0",
149 |     "sse-starlette==2.3.5",
150 |     "sshtunnel==0.4.0",
151 |     "stack-data==0.6.3",
152 |     "starlette==0.46.2",
153 |     "sumo==2.3.12",
154 |     "symfc==1.3.4",
155 |     "sympy==1.14.0",
156 |     "tabulate==0.9.0",
157 |     "threadpoolctl==3.6.0",
158 |     "tifffile==2025.5.10",
159 |     "tornado==6.4.2",
160 |     "tqdm==4.67.1",
161 |     "traitlets==5.14.3",
162 |     "typer==0.15.3",
163 |     "typing-extensions==4.13.2",
164 |     "typing-inspection==0.4.0",
165 |     "tzdata==2025.2",
166 |     "uncertainties==3.2.3",
167 |     "urllib3==2.4.0",
168 |     "uvicorn==0.34.2",
169 |     "wcwidth==0.2.13",
170 |     "werkzeug==3.1.3",
171 |     "wrapt==1.17.2",
172 |     "yarl==1.20.0",
173 | ]
174 | 
```

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

```python
   1 | import os
   2 | import logging
   3 | from typing import Optional, List, Union
   4 | from mcp.server.fastmcp import FastMCP
   5 | from pydantic import Field, AnyUrl
   6 | import matplotlib.pyplot as plt
   7 | from pymatgen.electronic_structure.plotter import BSPlotter
   8 | from pymatgen.electronic_structure.bandstructure import BandStructureSymmLine
   9 | from pymatgen.symmetry.analyzer import SpacegroupAnalyzer
  10 | from pymatgen.analysis.diffraction.xrd import XRDCalculator
  11 | from pymatgen.phonon.bandstructure import PhononBandStructureSymmLine
  12 | from pymatgen.phonon.plotter import PhononBSPlotter
  13 | from pymatgen.analysis.wulff import WulffShape
  14 | from emmet.core.electronic_structure import BSPathType
  15 | from typing import Literal
  16 | from dotenv import load_dotenv 
  17 | from mp_api.client import MPRester
  18 | import io 
  19 | import base64
  20 | import sys  
  21 | 
  22 | 
  23 | sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
  24 | # Setup logging
  25 | logging.basicConfig(level=logging.INFO)
  26 | logger = logging.getLogger("materials_project_mcp")
  27 | load_dotenv()
  28 | API_KEY = os.environ.get("MP_API_KEY")
  29 | 
  30 | # Create the MCP server instance
  31 | mcp = FastMCP()
  32 | 
  33 | 
  34 | def _get_mp_rester() -> MPRester:
  35 |     """
  36 |     Initialize and return a MPRester session with the user's API key.
  37 |     
  38 |     Returns:
  39 |         MPRester: An authenticated MPRester instance for querying the Materials Project API.
  40 |         
  41 |     Note:
  42 |         If no API key is found in environment variables, attempts to initialize without key.
  43 |     """
  44 |     if not API_KEY:
  45 |         logger.warning(
  46 |             "No MP_API_KEY found in environment. Attempting MPRester() without key."
  47 |         )
  48 |         return MPRester()
  49 |     return MPRester(API_KEY)
  50 | 
  51 | 
  52 | @mcp.tool()
  53 | async def search_materials(
  54 |     elements: Optional[List[str]] = Field(
  55 |         default=None,
  56 |         description="List of element symbols to filter by (e.g. ['Si', 'O']). If None, searches across all elements.",
  57 |     ),
  58 |     band_gap_min: float = Field(
  59 |         default=0.0, 
  60 |         description="Lower bound for band gap filtering in eV. Materials with band gaps below this value will be excluded.",
  61 |     ),
  62 |     band_gap_max: float = Field(
  63 |         default=10.0, 
  64 |         description="Upper bound for band gap filtering in eV. Materials with band gaps above this value will be excluded.",
  65 |     ),
  66 |     is_stable: bool = Field(
  67 |         default=False,
  68 |         description="If True, only returns materials that are thermodynamically stable (energy above hull = 0). If False, returns all materials.",
  69 |     ),
  70 |     max_results: int = Field(
  71 |         default=50, 
  72 |         ge=1, 
  73 |         le=200, 
  74 |         description="Maximum number of results to return. Must be between 1 and 200.",
  75 |     ),
  76 | ) -> str:
  77 |     """
  78 |     Search for materials in the Materials Project database using various filters.
  79 |     
  80 |     This function allows searching for materials based on their elemental composition,
  81 |     band gap range, and thermodynamic stability. Results are returned in a formatted
  82 |     markdown string containing material IDs, formulas, band gaps, and energy above hull values.
  83 |     
  84 |     Args:
  85 |         elements: Optional list of element symbols to filter by (e.g. ['Si', 'O'])
  86 |         band_gap_min: Minimum band gap in eV (default: 0.0)
  87 |         band_gap_max: Maximum band gap in eV (default: 10.0)
  88 |         is_stable: Whether to only return stable materials (default: False)
  89 |         max_results: Maximum number of results to return (default: 50, max: 200)
  90 |         
  91 |     Returns:
  92 |         str: A formatted markdown string containing the search results
  93 |         
  94 |     Example:
  95 |         >>> search_materials(elements=['Si', 'O'], band_gap_min=1.0, band_gap_max=5.0)
  96 |         Returns materials containing Si and O with band gaps between 1 and 5 eV
  97 |     """
  98 |     logger.info("Starting search_materials query...")
  99 |     with _get_mp_rester() as mpr:
 100 |         docs = mpr.materials.summary.search(
 101 |             elements=elements,
 102 |             band_gap=(band_gap_min, band_gap_max),
 103 |             is_stable=is_stable,
 104 |             fields=["material_id", "formula_pretty", "band_gap", "energy_above_hull"],
 105 |         )
 106 | 
 107 |     # Truncate results to max_results
 108 |     docs = list(docs)[:max_results]
 109 | 
 110 |     if not docs:
 111 |         return "No materials found matching your criteria."
 112 | 
 113 |     results_md = (
 114 |         f"## Materials Search Results\n\n"
 115 |         f"- **Elements**: {elements or 'Any'}\n"
 116 |         f"- **Band gap range**: {band_gap_min} eV to {band_gap_max} eV\n"
 117 |         f"- **Stable only**: {is_stable}\n\n"
 118 |         f"**Showing up to {max_results} matches**\n\n"
 119 |     )
 120 |     for i, mat in enumerate(docs, 1):
 121 |         results_md += (
 122 |             f"**{i}.** ID: `{mat.material_id}` | Formula: **{mat.formula_pretty}** | "
 123 |             f"Band gap: {mat.band_gap:.3f} eV | E above hull: {mat.energy_above_hull:.3f} eV\n"
 124 |         )
 125 |     return results_md
 126 | 
 127 | 
 128 | @mcp.tool()
 129 | async def get_structure_by_id(
 130 |     material_id: str = Field(
 131 |         ..., 
 132 |         description="Materials Project ID (e.g. 'mp-149'). Must be a valid MP ID."
 133 |     )
 134 | ) -> str:
 135 |     """
 136 |     Retrieve and format the crystal structure for a given material from the Materials Project.
 137 |     
 138 |     This function fetches the final computed structure for a material and returns a
 139 |     formatted summary including the lattice parameters, number of sites, and chemical formula.
 140 |     
 141 |     Args:
 142 |         material_id: The Materials Project ID of the material (e.g. 'mp-149')
 143 |         
 144 |     Returns:
 145 |         str: A formatted markdown string containing the structure information
 146 |         
 147 |     Example:
 148 |         >>> get_structure_by_id('mp-149')
 149 |         Returns the crystal structure information for silicon (mp-149)
 150 |     """
 151 |     logger.info(f"Fetching structure for {material_id}...")
 152 |     with _get_mp_rester() as mpr:
 153 |         structure = mpr.get_structure_by_material_id(material_id)
 154 | 
 155 |     if not structure:
 156 |         return f"No structure found for {material_id}."
 157 | 
 158 |     formula = structure.composition.reduced_formula
 159 |     lattice = structure.lattice
 160 |     sites_count = len(structure)
 161 |     text_summary = (
 162 |         f"## Structure for {material_id}\n\n"
 163 |         f"- **Formula**: {formula}\n"
 164 |         f"- **Lattice**:\n"
 165 |         f"   a = {lattice.a:.3f} Å, b = {lattice.b:.3f} Å, c = {lattice.c:.3f} Å\n"
 166 |         f"   α = {lattice.alpha:.2f}°, β = {lattice.beta:.2f}°, γ = {lattice.gamma:.2f}°\n"
 167 |         f"- **Number of sites**: {sites_count}\n"
 168 |         f"- **Reduced formula**: {structure.composition.reduced_formula}\n"
 169 |     )
 170 |     return text_summary
 171 | 
 172 | 
 173 | @mcp.tool()
 174 | async def get_electronic_bandstructure(
 175 |     material_id: str = Field(
 176 |         ..., 
 177 |         description="Materials Project ID (e.g. 'mp-149'). Must be a valid MP ID."
 178 |     ),
 179 |     path_type: Literal["setyawan_curtarolo", "hinuma", "latimer_munro", "uniform"] = Field(
 180 |         default="setyawan_curtarolo",
 181 |         description="Type of k-point path to use for the band structure plot. Options are:\n"
 182 |                    "- setyawan_curtarolo: Standard path for cubic systems\n"
 183 |                    "- hinuma: Standard path for hexagonal systems\n"
 184 |                    "- latimer_munro: Alternative path for cubic systems\n"
 185 |                    "- uniform: Uniform k-point sampling (not recommended for plotting)"
 186 |     ),
 187 | ):
 188 |     """
 189 |     Generate and return a electronic band structure plot for a given material.
 190 |     
 191 |     This function fetches the band structure data from the Materials Project and creates
 192 |     a plot showing the electronic band structure along high-symmetry k-points. The plot
 193 |     is returned as a base64-encoded PNG image embedded in a markdown string.
 194 |     
 195 |     Args:
 196 |         material_id: The Materials Project ID of the material (e.g. 'mp-149')
 197 |         path_type: The type of k-point path to use for the band structure plot
 198 |         
 199 |     Returns:
 200 |         A plot of the electronic band structure 
 201 |         
 202 |     Example:
 203 |         >>> get_electronic_bandstructure('mp-149', path_type='setyawan_curtarolo')
 204 |         Returns a band structure plot for silicon using the standard cubic path
 205 |     """
 206 |     logger.info(f"Plotting band structure for {material_id} with path_type: {path_type}")
 207 | 
 208 |     with _get_mp_rester() as mpr:
 209 |         if path_type == "uniform":
 210 |             bs = mpr.get_bandstructure_by_material_id(material_id, line_mode=False)
 211 |         else:
 212 |             bs = mpr.get_bandstructure_by_material_id(
 213 |                 material_id, path_type=BSPathType(path_type)
 214 |             )
 215 | 
 216 |     if not isinstance(bs, BandStructureSymmLine):
 217 |         return f"Cannot plot `{path_type}` band structure. Only line-mode paths are plottable."
 218 |     
 219 |     # Generate the plot 
 220 |     plotter = BSPlotter(bs)
 221 |     ax = plotter.get_plot()
 222 |     fig = ax.get_figure()  
 223 |     
 224 | 
 225 | 
 226 |     plt.title(f"Band Structure for {material_id} using {path_type} path")   
 227 |     plt.ylabel("Energy (eV)")
 228 |     plt.tight_layout()
 229 |     #plot the image 
 230 |     # Save the figure to a buffer
 231 |     buffer = io.BytesIO()
 232 |     fig.savefig(buffer, format='png', dpi=150, bbox_inches='tight')
 233 |     buffer.seek(0)
 234 |     #plt.close(fig)  # Close the figure to free memory
 235 |     # return image object 
 236 |     return fig
 237 | 
 238 |     
 239 | 
 240 | 
 241 |     ## save to buffer 
 242 |     #buffer = io.BytesIO()
 243 |     #fig.savefig(buffer, format='png', dpi=70, bbox_inches='tight')
 244 |     #plt.close(fig)  # Close the figure to free memory
 245 | 
 246 |     ## figure dimensions 
 247 |     #fig_width = fig.get_figwidth() * fig.dpi
 248 |     #fig_height = fig.get_figheight() * fig.dpi
 249 | 
 250 | 
 251 |     #band_image_data = buffer.getvalue()
 252 |     #image_base64 = base64.b64encode(band_image_data).decode('ascii')
 253 | 
 254 |     #return {
 255 |     #    "success": True,   
 256 |     #    "material_id": material_id,
 257 |     #    "image_base64": image_base64[:40000],
 258 |     #    "metadata": {
 259 |     #           # "material_id": material_id,
 260 |     #            "path_type": path_type,
 261 |     #            "description": f"Band structure plot for material {material_id} using {path_type} path",
 262 |     #            "width": int(fig_width),
 263 |     #            "height": int(fig_height)
 264 |     #        }
 265 |     #}
 266 | 
 267 | 
 268 | @mcp.tool()
 269 | async def get_electronic_dos_by_id(
 270 |     material_id: str = Field(
 271 |         ..., 
 272 |         description="Materials Project ID (e.g. 'mp-149'). Must be a valid MP ID."
 273 |     ),
 274 | ) -> str:
 275 |     """
 276 |     Retrieve the electronic density of states (DOS) data for a given material.
 277 |     
 278 |     This function fetches the electronic density of states data from the Materials Project
 279 |     for the specified material. The DOS data includes information about the
 280 |     electronic states available to electrons in the material.
 281 |     
 282 |     Args:
 283 |         material_id: The Materials Project ID of the material (e.g. 'mp-149')
 284 |         
 285 |     Returns:
 286 |         str: A string containing the density of states information
 287 |         
 288 |     Example:
 289 |         >>> get_electronic_dos_by_id('mp-149')
 290 |         Returns the electronic density of states data for silicon
 291 |     """   
 292 |     logger.info(f"Fetching electronic density of states for {material_id}...")
 293 |     with _get_mp_rester() as mpr:
 294 |         dos = mpr.get_dos_by_material_id(material_id)
 295 | 
 296 |     if not dos:
 297 |         return f"No density of states found for {material_id}."
 298 |     
 299 |     return f"Electronic density of states for {material_id}: {dos}"
 300 | 
 301 | #phonons
 302 | @mcp.tool()
 303 | async def get_phonon_bandstructure(
 304 |     material_id: str = Field(
 305 |         ..., 
 306 |         description="Materials Project ID (e.g. 'mp-149'). Must be a valid MP ID."
 307 |     ),
 308 | ) -> str:
 309 |     """
 310 |     Retrieve the phonon band structure for a given material.
 311 |     
 312 |     This function fetches the phonon band structure data from the Materials Project
 313 |     for the specified material. The phonon band structure includes information about
 314 |     the vibrational modes and frequencies of the material.
 315 |     
 316 |     Args:
 317 |         material_id: The Materials Project ID of the material (e.g. 'mp-149')
 318 |         
 319 |     Returns:
 320 |         A plot of the phonon band structure 
 321 |     """
 322 | 
 323 |     logger.info(f"Fetching phonon band structure for {material_id}...")
 324 |     with _get_mp_rester() as mpr:
 325 |         bs = mpr.get_phonon_bandstructure_by_material_id(material_id)
 326 | 
 327 |     if not isinstance(bs, PhononBandStructureSymmLine):
 328 |         return "Cannot plot phonon band structure. Only line-mode paths are plottable."    
 329 | 
 330 |     plotter = PhononBSPlotter(bs)
 331 |     fig = plotter.get_plot()
 332 |     
 333 |     plt.title(f"Phonon Band Structure for {material_id}")
 334 |     plt.ylabel("Frequency (THz)")
 335 |     plt.tight_layout()
 336 |     # Save the figure to a buffer
 337 |     buffer = io.BytesIO()
 338 |     fig.savefig(buffer, format='png', dpi=300, bbox_inches='tight')
 339 |     plt.close(fig)
 340 | 
 341 |     # Convert the buffer to base64
 342 |     phonon_image_data = buffer.getvalue()
 343 |     image_base64 = base64.b64encode(phonon_image_data).decode('ascii')
 344 |     
 345 |     # figure dimensions
 346 |     fig_width = fig.get_figwidth() * fig.dpi
 347 |     fig_height = fig.get_figheight() * fig.dpi
 348 | 
 349 |     
 350 |     return {
 351 |         "success": True,
 352 |         "material_id": material_id,
 353 |         "image_base64": image_base64,
 354 |         "metadata": {
 355 |             "path_type": "phonon",
 356 |             "description": f"Phonon band structure plot for material {material_id}",
 357 |             "width": int(fig_width),
 358 |             "height": int(fig_height)
 359 |         }
 360 |     }
 361 |  
 362 | 
 363 | 
 364 | 
 365 | @mcp.tool()
 366 | async def get_phonon_dos_by_id(
 367 |     material_id: str = Field(
 368 |         ..., 
 369 |         description="Materials Project ID (e.g. 'mp-149'). Must be a valid MP ID."
 370 |     ),
 371 | ) -> str:
 372 |     """
 373 |     Retrieve the phonon density of states (DOS) data for a given material.
 374 |     
 375 |     This function fetches the phonon density of states data from the Materials Project
 376 |     for the specified material. The DOS data includes information about the
 377 |     vibrational modes and frequencies of the material.
 378 |     """
 379 |     logger.info(f"Fetching phonon density of states for {material_id}...")
 380 |     with _get_mp_rester() as mpr:
 381 |         dos = mpr.get_phonon_dos_by_material_id(material_id)
 382 | 
 383 |     if not dos:
 384 |         return f"No density of states found for {material_id}."
 385 | 
 386 |     return f"Phonon density of states for {material_id}: {dos}"
 387 |     
 388 | 
 389 | @mcp.tool()
 390 | async def get_ion_reference_data_for_chemsys(
 391 |     chemsys: Optional[Union[List, str]] = Field(
 392 |         ..., 
 393 |         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']"
 394 |     )
 395 | ) -> str: 
 396 |     """
 397 |     Downloads aqueouse  ion reference data used in the contruction Pourbaix 
 398 |     The data returned from this method can be passed to get_ion_entries(). 
 399 | 
 400 |     Args:
 401 |         chemsys (str | list):  Chemical system string comprising element
 402 |                 symbols separated by dashes, e.g., "Li-Fe-O" or List of element
 403 |                 symbols, e.g., ["Li", "Fe", "O"].
 404 | 
 405 |     Returns:
 406 |             str: markdown format of the reference data for ions 
 407 |     """
 408 | 
 409 |     logger.info("Fetch reference data for ion by Chemsys")
 410 |     mpr_rester = _get_mp_rester()
 411 | 
 412 |     with mpr_rester as mpr: 
 413 |         ion_reference_data = mpr.get_ion_reference_data_for_chemsys(chemsys=chemsys)
 414 | 
 415 |     if not ion_reference_data: 
 416 |         logger.info(f"data not found for {chemsys}")
 417 |         return f"No ion reference data for {chemsys}"
 418 | 
 419 | 
 420 |     ion_data = f"Ion Reference Data for Chemical System: {chemsys}\n\n"
 421 | 
 422 |     for idx, ion in enumerate(ion_reference_data, 1): 
 423 |         identifier = ion.get("identifier", "Unknown")
 424 |         formula = ion.get("formula", "Unknown")
 425 |         data = ion.get("data", {})
 426 | 
 427 |         # get the properties for idx 
 428 |         charge_info = data.get("charge", {})
 429 |         charge_value = charge_info.get('value', 0)
 430 |         charge_display = charge_info.get('display', str(charge_value))
 431 |             
 432 |         delta_gf_info = data.get('ΔGᶠ', {})
 433 |         delta_gf_value = delta_gf_info.get('value', 'N/A')
 434 |         delta_gf_display = delta_gf_info.get('display', f'{delta_gf_value} kJ/mol' if delta_gf_value != 'N/A' else 'N/A')
 435 | 
 436 |         maj_elements = data.get('MajElements', 'Unknown')
 437 |         ref_solid = data.get('RefSolid', 'Unknown')
 438 | 
 439 |         ref_solid_info = data.get('ΔGᶠRefSolid', {})
 440 |         ref_solid_value = ref_solid_info.get('value', 'N/A')
 441 |         ref_solid_display = ref_solid_info.get('display', f'{ref_solid_value} kJ/mol' if ref_solid_value != 'N/A' else 'N/A')
 442 | 
 443 |         reference = data.get('reference', 'No reference provided')
 444 | 
 445 |         ion_data +=  f"""## {idx}. {identifier}
 446 | 
 447 |             | Property | Value |
 448 |             |----------|--------|
 449 |             | *Formula* | {formula} |
 450 |             | *Charge* | {charge_display} |
 451 |             | *Formation Energy (ΔGᶠ)* | {delta_gf_display} |
 452 |             | *Major Elements* | {maj_elements} |
 453 |             | *Reference Solid* | {ref_solid} |
 454 |             | *Ref. Solid ΔGᶠ* | {ref_solid_display} |
 455 | 
 456 |             *Reference:* {reference}
 457 |         """
 458 | 
 459 |     return ion_data
 460 | 
 461 | 
 462 | @mcp.tool()
 463 | async def get_cohesive_energy(
 464 |         material_ids: List[str] = Field(
 465 |             ...,
 466 |             description="List of Material IDs to compute cohesive energies"
 467 |         ),
 468 |         normalization: str = Field(
 469 |             default="atom",
 470 |             description="The normalization to use, whether to normalize cohesive energy by number of atoms (deflaut) or by number of formula units  "
 471 |         )
 472 | ) -> str:
 473 |     """
 474 |     Obtain the cohesive energy of the structure(s) corresponding to single or multiple material IDs
 475 | 
 476 |     Args:
 477 |         material_ids: List to material IDs to compute their cohesive energy
 478 |         normalization: Whether to normalize cohesive energy using number of atoms or number of formula
 479 | 
 480 |     Returns:
 481 |         str: The Markdown of  cohesive energies (in eV/atom or eV/formula unit) for
 482 |             each material, indexed by Material IDs .
 483 | 
 484 |     """
 485 |     logger.info("Getting cohesive energy for material IDs")
 486 | 
 487 |     with _get_mp_rester() as mpr:
 488 |         cohesive_energies = mpr.get_cohesive_energy(material_ids=material_ids, normalization=normalization)
 489 | 
 490 |     if not cohesive_energies:
 491 |         logger.info(f"No cohesive energy was retrived for {material_ids}")
 492 |         return f"No cohesive energies found for these Material IDs: {material_ids}"
 493 | 
 494 | 
 495 |     energies = f"## Cohesive Energies \n"
 496 |     for identifier, energy in cohesive_energies.items():
 497 |         unit = "eV/atom" if normalization == "atom" else "eV/formula unit"
 498 |         energies += f"-- **{identifier}** : {energy} {unit}\n"
 499 | 
 500 |     return energies
 501 | 
 502 | 
 503 | @mcp.tool()
 504 | async def get_atom_reference_data(
 505 |         funcs: tuple[str, ...] = Field(
 506 |             default=("PBE",),
 507 |             description="list of functionals to retrieve data for "
 508 |         )
 509 | ) -> str:
 510 |     """
 511 |     Retrieve reference energies of isolated neutral atoms. this energies can be used to calculate formations energies of compounds,
 512 |     Write the meaning of these funcs eg thier full names
 513 |     Args:
 514 |         funcs ([str] or None ) : list of functionals to retrieve data for.
 515 |     Returns:
 516 |         str : Markdown containing isolated atom energies 
 517 |     """
 518 |     logger.info("Getting Atom Reference Data")
 519 |     with _get_mp_rester() as mpr:
 520 |         atom_data = mpr.get_atom_reference_data(funcs=funcs)
 521 | 
 522 |     if not atom_data:
 523 |         return f"No atom data retrieved for functionals {funcs}"
 524 | 
 525 |     atom_references = "| Element | Reference Energy (eV/atom) |\n"
 526 | 
 527 |     for element, energy in atom_data.items():
 528 |         atom_references += f"| **{element}** | {energy} | \n"
 529 | 
 530 |     return atom_references
 531 | 
 532 | 
 533 | 
 534 | @mcp.tool()
 535 | async def get_magnetic_data_by_id(
 536 |         material_ids: list[str] = Field(
 537 |             ...,
 538 |             description="Material ID of the material"
 539 |         ),
 540 | ) -> str: 
 541 |     """
 542 |     Get magnetic data using material ID. The materials api provides computed
 543 |     magnetic propertics from Density Functional Theory (DFT) calculations. This includes
 544 |     1. Magnetic ordering
 545 |     2. Total Magnetization
 546 |     3. Site-projected Magnetic Moments
 547 |     4. Spin-polarized electronic structures
 548 | 
 549 |     Args:
 550 |         material_id: Material ID of the material e.g., mp-20664, which is Mn2Sb
 551 |     
 552 |     Returns:
 553 |         (str): returns a markdown string containing the magnetic data for the material.
 554 |     """
 555 |     logger.info(f"Getting magnetic data for material{material_ids}")
 556 |     with _get_mp_rester() as mpr:
 557 |         magnetic_data = mpr.magnetism.search(material_ids=material_ids)
 558 | 
 559 | 
 560 |     if not magnetic_data:
 561 |         logger.info(f"Not data collected for {material_ids}")
 562 |         return f"No magnetic data found for material {material_ids}"
 563 |     
 564 |     data_md  = f"|##      Magnetic Data for Material IDs    |\n\n"
 565 |     for idx, model in enumerate(magnetic_data): 
 566 |         data_md += f"idx : {idx}"
 567 |         data = model.model_dump()
 568 |         for key, value in data.items(): 
 569 |             data_md += f"| **{key}       :         {value}   |\n\n"
 570 |             
 571 |     
 572 |     return data_md
 573 |     
 574 | 
 575 | 
 576 | @mcp.tool()
 577 | async def get_charge_density_by_id(
 578 |         material_ids: str = Field(
 579 |             ...,
 580 |             description="Material ID of the material"
 581 |         )
 582 | ):
 583 |     """
 584 |     Get charge density data for a given materials project ID
 585 |     Args:
 586 |         material_id: Material Project ID
 587 | 
 588 |     Returns:
 589 |         str :
 590 | 
 591 |     """
 592 |     logging.info(f"Getting charge density of material {material_ids}")
 593 |     with _get_mp_rester() as mpr:
 594 |         charge_density = mpr.get_charge_density_from_material_id(material_id=material_ids)
 595 |         logger.info(f"Charge density data retrieved for {material_ids}")
 596 |         
 597 | 
 598 |     if not charge_density:
 599 |         return f"No data found for material {charge_density}"
 600 | 
 601 |     density_data = f"""
 602 |             ## Material ID: {material_ids}
 603 |         
 604 |             ### Structure Summary:
 605 |             {charge_density.structure}
 606 |         
 607 |             ### Charge Density (Total):
 608 |             {charge_density.data["total"]}
 609 |             
 610 |             ### Is charge Density Polarized : 
 611 |             {charge_density.is_spin_polarized}
 612 |             
 613 |         """
 614 | 
 615 |     return density_data
 616 | 
 617 | 
 618 | @mcp.tool()
 619 | async def get_dielectric_data_by_id(
 620 |         material_id: str = Field(
 621 |             ..., 
 622 |             description="Material ID of the material"
 623 |     )
 624 | ) -> str: 
 625 |     """
 626 |     Gets the dielectric data for a given material. Dielectric is a 
 627 |     material the can be polarized by an applied electric field. 
 628 |     The mathematical description of the dielectric effect is a tensor 
 629 |     constant of proportionality that relates an externally applied electric 
 630 |     field to the field within the material
 631 |     
 632 |     Args: 
 633 |         material_id (str): Material ID for the material
 634 | 
 635 |     Returns: 
 636 |         str: markdown of the dielectric data
 637 | 
 638 | 
 639 |     """
 640 |     logger.info(f"Getting Dielectric data for material: {material_id}")
 641 |     with _get_mp_rester() as mpr: 
 642 |         dielectric_data = mpr.materials.dielectric.search(material_id)
 643 |     
 644 |     if not dielectric_data: 
 645 |         logger.info(f"No data found for material {material_id}")
 646 |         return f"No data for the material: {material_id}"
 647 | 
 648 |     data_md  = f"|##    Dielectric Data  for Material IDs    |\n\n"
 649 |     for idx, model in enumerate(dielectric_data): 
 650 |         data_md += f"idx : {idx}"
 651 |         data = model.model_dump()
 652 |         for key, value in data.items(): 
 653 |             data_md += f"| **{key}    :    {value}   |\n\n"
 654 |             
 655 |     return data_md
 656 |     
 657 | 
 658 | 
 659 | @mcp.tool()
 660 | async def get_diffraction_patterns(
 661 |     material_id : str = Field(
 662 |         ..., 
 663 |         description="Material ID of the material "
 664 |     )
 665 | ) -> str: 
 666 |     """
 667 |     Gets diffraction patterns of a material given its ID. 
 668 |     Diffraction occurs when waves (electrons, x-rays, neutrons)
 669 |     scattering from obstructions act as a secondary sources of propagations
 670 | 
 671 |     Args: 
 672 |         material id (str): the material id of the material to get the diffracton pattern 
 673 | 
 674 | 
 675 |     Return: 
 676 |         str: markdown of the patterns 
 677 |     
 678 |     """
 679 |     logger.info(f"Getting the Diffraction Pattern of element: {material_id}")
 680 |   
 681 |     with _get_mp_rester() as mpr: 
 682 |         # first retrieve the relevant structure 
 683 |         structure = mpr.get_structure_by_material_id(material_id)
 684 |     try: 
 685 |         sga = SpacegroupAnalyzer(structure=structure)
 686 |         conventional_structure  = sga.get_conventional_standard_structure()
 687 |         calculator = XRDCalculator(wavelength="CuKa")
 688 |         pattern = calculator.get_pattern(conventional_structure)
 689 |         return str(pattern)
 690 |     except: 
 691 |         logging.error("Error occurred when function get_diffraction_patterns ")
 692 |         return f"No diffraction pattern retrieved for material : {material_id}"
 693 |     
 694 | 
 695 | 
 696 |     
 697 | @mcp.tool()
 698 | async def get_xRay_absorption_spectra(
 699 |     material_ids: List[str] = Field(
 700 |         ..., 
 701 |         description="Material ID of the material"
 702 |     )
 703 | ) -> str:
 704 |     """
 705 |     Obtain X-ray Absorption Spectra using single or multiple IDs,
 706 |     following the methodology as discussed by Mathew et al and Chen et al.
 707 | 
 708 |     Args: 
 709 |         material_ids (List[str]) : material_ids of the elements
 710 |     
 711 |     Return: 
 712 |         str: 
 713 |     
 714 |     """
 715 |     logging.info("")
 716 |     with _get_mp_rester() as mpr: 
 717 |         xas_doc = mpr.materials.xas.search(material_ids=material_ids)
 718 | 
 719 |     if not xas_doc: 
 720 |         logging.info(f"No data retrieve for material(s) : {material_ids}")
 721 |         return f"No data retrieve for material(s) : {material_ids}"
 722 | 
 723 |     data_md = f"|##  X-ray absorption spectra for Material IDs    |\n\n"
 724 |     for idx, model in enumerate(xas_doc):
 725 |         data_md += f"idx : {idx}"
 726 |         data = model.model_dump()
 727 |         for key, value in data.items():
 728 |             data_md += f"| **{key}  :  {value}   |\n\n"
 729 | 
 730 |     return data_md
 731 | 
 732 | 
 733 | @mcp.tool()
 734 | async def get_elastic_constants(
 735 |     material_ids: List[str] = Field(
 736 |         ..., 
 737 |         description="Material ID of the material"
 738 |     )
 739 | ):
 740 |     """
 741 |     Obtain Elastic constants given material IDs.
 742 |     Elasticity describes a material's ability to resist deformations
 743 |     (i.e. size and shape) when subjected to external forces.
 744 | 
 745 |     :param material_ids :   material ID(s) of the elements
 746 | 
 747 |     :return:
 748 |         str: markdown of the elastic constants
 749 | 
 750 |     """
 751 |     logging.info(f"Getting Elastic Constant for material(s): {material_ids}")
 752 |     with _get_mp_rester() as mpr:
 753 |         elasticity_doc = mpr.materials.elasticity.search(material_ids=material_ids)
 754 | 
 755 |     if not elasticity_doc:
 756 |         return f"No Elasticity data retrieved for material: {material_ids}"
 757 | 
 758 |     data_md = f"|##     Elastic Constants   |\n\n"
 759 |     for idx, model in enumerate(elasticity_doc):
 760 |         data_md += f"idx : {idx}"
 761 |         data = model.model_dump()
 762 |         for key, value in data.items():
 763 |             data_md += f"| **{key}  :  {value}   |\n\n"
 764 | 
 765 |     return data_md
 766 | 
 767 | 
 768 | 
 769 |  
 770 | 
 771 | @mcp.tool()
 772 | async def get_suggested_substrates(
 773 |     material_id: str = Field(
 774 |         ..., 
 775 |         description="Material ID of the material"
 776 |     )
 777 | ) -> str: 
 778 |     """
 779 |     Obtains Suggested substrates for a film material. 
 780 |     It helps to find suitable substrate materials for thin films 
 781 |     
 782 |     Args: 
 783 |         material_id (str): material ID of the material 
 784 |     
 785 |     Returns: 
 786 |         str: markdown of the data 
 787 |     
 788 |     """
 789 |     logging.info(f"Getting suggested substrates for the material : {material_id}")
 790 |     with _get_mp_rester() as mpr: 
 791 |         substrates_doc = mpr.materials.substrates.search(film_id=material_id)
 792 | 
 793 |     if not substrates_doc: 
 794 |         return f"No substrates gotten for material: {material_id}"
 795 | 
 796 |     sub_md = f""
 797 |     for idx, data in enumerate(substrates_doc):
 798 |         sub_md += f"## Substrate {idx + 1}\n\n"
 799 |         # Create a detailed view for each substrate
 800 |         sub_md += f"- **Index**: {idx}\n"
 801 |         sub_md += f"- **Substrate Formula**: {getattr(data, 'sub_form', 'N/A')}\n"
 802 |         sub_md += f"- **Substrate ID**: {getattr(data, 'sub_id', 'N/A')}\n"
 803 |         sub_md += f"- **Film Orientation**: {getattr(data, 'film_orient', 'N/A')}\n"
 804 |         sub_md += f"- **Area**: {getattr(data, 'area', 'N/A')}\n"
 805 |         sub_md += f"- **Energy**: {getattr(data, 'energy', 'N/A')}\n"
 806 |         sub_md += f"- **Film ID**: {getattr(data, 'film_id', 'N/A')}\n"
 807 |         sub_md += f"- **Orientation**: {getattr(data, 'orient', 'N/A')}\n\n"
 808 |         sub_md += "---\n\n"
 809 |     
 810 |     return sub_md
 811 | 
 812 | 
 813 | 
 814 | @mcp.tool()
 815 | async def get_thermo_stability(
 816 |     material_ids: List[str] = Field(
 817 |         ..., 
 818 |         description="Materials IDs of the material"
 819 |     ), 
 820 |     thermo_types: List[str] = Field(
 821 |         default=["GGA_GGA+U_R2SCAN"], 
 822 |         description=""
 823 |     )
 824 | ) -> str: 
 825 |     """
 826 |     Obtains thermodynamic stability data for a material
 827 | 
 828 |     Args: 
 829 |         material_ids (List[str]) : A list of the material ID(s) eg. ["mp-861883"]
 830 |         thermo_types (List[str]) : 
 831 | 
 832 |     Returns: 
 833 |         str: Markdown of the thermodynamic stability data 
 834 |     
 835 |     """
 836 |     logging.info(f"Getting thermodynamic stability for material(s):{material_ids}")
 837 |     with _get_mp_rester() as mpr: 
 838 |         thermo_docs = mpr.materials.thermo.search(
 839 |             material_ids=material_ids, 
 840 |             thermo_types=thermo_types
 841 |         )
 842 | 
 843 |     if not thermo_docs: 
 844 |         logging.info("No thermodynamic stability data retrieved for ")
 845 |         return f"No thermodynamic stability data retrieved for materials: {material_ids}"
 846 |     
 847 |     thermo_md = f"Thermodynamic Stability for: {material_ids}"
 848 |     for idx, data in enumerate(thermo_docs):
 849 |         thermo_md += f"\n--- Material {idx + 1} ---\n"
 850 |        
 851 |         energy_above_hull = getattr(data, "energy_above_hull", "Not available")
 852 |         thermo_md += f"| ** | energy_above_hull : {energy_above_hull} | \n"
 853 |       
 854 |         formation_energy = getattr(data, "formation_energy_per_atom", "Not available")
 855 |         thermo_md += f"| ** | formation_energy_per_atom : {formation_energy} | \n"
 856 |         
 857 |         thermo_type = getattr(data, "thermo_type", "Not available")
 858 |         thermo_md += f"| ** | thermo_type : {thermo_type} | \n"
 859 |         
 860 |         
 861 |         is_stable = getattr(data, "is_stable", False)
 862 |         thermo_md += f"| ** | is_stable : {is_stable} | \n"
 863 |     
 864 |         formula_pretty = getattr(data, 'formula_pretty', 'Not available')
 865 |         thermo_md += f"| ** | formula : {formula_pretty} | \n"
 866 | 
 867 |     return thermo_md
 868 | 
 869 | 
 870 | @mcp.tool()
 871 | async def get_surface_properties(
 872 |         material_id: str = Field(
 873 |             ...,
 874 |             description="Material ID of the material"
 875 |         ),
 876 | ) -> str:
 877 |     """
 878 |     Gets Surface properties data for materials as discussed by
 879 |     the methodology by Tran et. al.
 880 | 
 881 |     :param
 882 |         material_id: Material ID for the material
 883 |         response_limit (int) : Response limit for each call
 884 |     :return:
 885 |         Markdown of the surface data
 886 | 
 887 |     """
 888 |     logging.info(f"Getting surface data for material: {material_id}")
 889 |     with _get_mp_rester() as mpr:
 890 |         surface_docs = mpr.materials.surface_properties.search(material_id)
 891 | 
 892 |     if not surface_docs:
 893 |         logging.info(f"No surface data retrieved for material: {material_id}")
 894 |         return f"No surface data retrieved for material: {material_id}"
 895 | 
 896 |     surface_md = f"# Surface Properties for material: {material_id}\n\n"
 897 | 
 898 |     # surface_docs is a list of surface documents
 899 |     for idx, surface_doc in enumerate(surface_docs):
 900 |         # Access the surfaces from each document
 901 |         if hasattr(surface_doc, 'surfaces') and surface_doc.surfaces:
 902 |             for surface_idx, surface in enumerate(surface_doc.surfaces):
 903 |                 miller_index = surface.miller_index
 904 |                 miller_str = f"({miller_index[0]}{miller_index[1]}{miller_index[2]})"
 905 | 
 906 |                 surface_md += f"## Surface {idx + 1}.{surface_idx + 1}: {miller_str}\n\n"
 907 |                 surface_md += f"- **Miller Index:** {miller_index}\n"
 908 |                 surface_md += f"- **Surface Energy:** {surface.surface_energy:.4f} J/m²\n"
 909 |                 surface_md += f"- **Surface Energy (eV/Ų):** {getattr(surface, 'surface_energy_EV_PER_ANG2', 'N/A')}\n"
 910 |                 surface_md += f"- **Work Function:** {getattr(surface, 'work_function', 'N/A')} eV\n"
 911 |                 surface_md += f"- **Fermi Energy:** {getattr(surface, 'efermi', 'N/A')} eV\n"
 912 |                 surface_md += f"- **Area Fraction:** {getattr(surface, 'area_fraction', 'N/A')}\n"
 913 |                 surface_md += f"- **Is Reconstructed:** {'Yes' if getattr(surface, 'is_reconstructed', False) else 'No'}\n"
 914 |                 surface_md += f"- **Has Wulff Shape:** {'Yes' if getattr(surface, 'has_wulff', False) else 'No'}\n\n"
 915 | 
 916 |         # Add material properties from the document
 917 |         surface_md += f"### Material Properties\n\n"
 918 |         surface_md += f"- **Material ID:** {getattr(surface_doc, 'material_id', 'N/A')}\n"
 919 |         surface_md += f"- **Formula:** {getattr(surface_doc, 'formula_pretty', 'N/A')}\n"
 920 |         surface_md += f"- **Crystal System:** {getattr(surface_doc, 'crystal_system', 'N/A')}\n"
 921 |         surface_md += f"- **Space Group:** {getattr(surface_doc, 'space_group', 'N/A')}\n\n"
 922 | 
 923 |     return surface_md
 924 | 
 925 | 
 926 | @mcp.tool()
 927 | async def get_grain_boundaries(
 928 |         material_id: str = Field(
 929 |             ...,
 930 |             description="Material ID of the material"
 931 |         )
 932 | ):
 933 | 
 934 |     """
 935 |     Get Computed Grain Boundaries for a material.
 936 | 
 937 |     :param material_id (str): Material ID of the material
 938 | 
 939 |     :return:
 940 |         Markdown of the grain boundaries data
 941 |     """
 942 |     logger.info(f"Getting Grain Boundaries for material: {material_id}")
 943 |     with _get_mp_rester() as mpr:
 944 |         grain_boundary_docs = mpr.materials.grain_boundaries.search(material_id)
 945 | 
 946 |     if not grain_boundary_docs:
 947 |         logger.info(f"No Grain Boundaries data for material: {material_id}")
 948 |         return f"No Grain Boundaries data for material: {material_id}"
 949 | 
 950 |     grain_md = f"# Grain Boundaries for material: {material_id} \n\n"
 951 |     for idx, data in enumerate(grain_boundary_docs):
 952 |         grain_md += f"- **Initial Structure : ** {getattr(data, "initial_structure", "N/A")}\n\n"
 953 |         grain_md += f"- ** Final Structure : ** {getattr(data, "final_structure", "N/A")} \n\n"
 954 |     return grain_md
 955 | 
 956 | 
 957 | @mcp.tool()
 958 | async def get_insertion_electrodes(
 959 |         material_id: str = Field(
 960 |             ...,
 961 |             description="Material ID of the material"
 962 |         )
 963 | ) -> str:
 964 |     """
 965 |     Get Insertion Electrodes data for a material.
 966 | 
 967 | 
 968 |     :param 
 969 |         material_id (str): Material ID of the material
 970 |     :return
 971 |         str: Markdown of the Insertion Electrodes data
 972 | 
 973 |     """
 974 |     logger.info(f"Getting Insertion Electrodes data for material: {material_id}")
 975 |     with _get_mp_rester() as mpr:
 976 |         electrodes_docs = mpr.materials.insertion_electrodes.search(material_id)
 977 | 
 978 |     
 979 |     if not electrodes_docs:
 980 |         logger.info(f"No Insertion Electrodes data for material: {material_id}")
 981 |         return f"No Insertion Electrodes data for material: {material_id}"
 982 |     
 983 |     electrodes_md = f"# Insertion Electrodes for material: {material_id}\n\n"
 984 |     for idx, data in enumerate(electrodes_docs):
 985 |         electrodes_md += f"## Electrode {idx + 1}\n\n"
 986 |         electrodes_md += f"- **Battery Type:** {getattr(data, 'battery_type', 'N/A')}\n"
 987 |         electrodes_md += f"- **Battery ID:** {getattr(data, 'battery_id', 'N/A')}\n"
 988 |         electrodes_md += f"- **Battery Formula:** {getattr(data, 'battery_formula', 'N/A')}\n"
 989 |         electrodes_md += f"- **Working Ion:** {getattr(data, 'working_ion', 'N/A')}\n"
 990 |         electrodes_md += f"- **Number of Steps:** {getattr(data, 'num_steps', 'N/A')}\n"
 991 |         electrodes_md += f"- **Max Voltage Step:** {getattr(data, 'max_voltage_step', 'N/A')}\n"
 992 |         electrodes_md += f"- **Last Updated:** {getattr(data, 'last_updated', 'N/A')}\n"
 993 |         electrodes_md += f"- **Framework:** {getattr(data, 'framework', 'N/A')}\n"
 994 |         electrodes_md += f"- **Framework Formula:** {getattr(data, 'framework_formula', 'N/A')}\n"
 995 |         electrodes_md += f"- **Elements:** {getattr(data, 'elements', 'N/A')}\n"
 996 |         electrodes_md += f"- **Number of Elements:** {getattr(data, 'nelements', 'N/A')}\n"
 997 |         electrodes_md += f"- **Chemical System:** {getattr(data, 'chemsys', 'N/A')}\n"
 998 |         electrodes_md += f"- **Formula Anonymous:** {getattr(data, 'formula_anonymous', 'N/A')}\n"
 999 |         electrodes_md += f"- **Warnings:** {getattr(data, 'warnings', 'N/A')}\n"
1000 |         electrodes_md += f"- **Formula Charge:** {getattr(data, 'formula_charge', 'N/A')}\n"
1001 |         electrodes_md += f"- **Formula Discharge:** {getattr(data, 'formula_discharge', 'N/A')}\n"
1002 |         electrodes_md += f"- **Max Delta Volume:** {getattr(data, 'max_delta_volume', 'N/A')}\n"
1003 |         electrodes_md += f"- **Average Voltage:** {getattr(data, 'average_voltage', 'N/A')}\n"
1004 |         electrodes_md += f"- **Capacity Gravimetric:** {getattr(data, 'capacity_grav', 'N/A')}\n"
1005 |         electrodes_md += f"- **Capacity Volumetric:** {getattr(data, 'capacity_vol', 'N/A')}\n"
1006 |         electrodes_md += f"- **Energy Gravimetric:** {getattr(data, 'energy_grav', 'N/A')}\n"
1007 |         electrodes_md += f"- **Energy Volumetric:** {getattr(data, 'energy_vol', 'N/A')}\n"
1008 |         electrodes_md += f"- **Fraction A Charge:** {getattr(data, 'fracA_charge', 'N/A')}\n"
1009 |         electrodes_md += f"- **Fraction A Discharge:** {getattr(data, 'fracA_discharge', 'N/A')}\n"
1010 |         electrodes_md += f"- **Stability Charge:** {getattr(data, 'stability_charge', 'N/A')}\n"
1011 |         electrodes_md += f"- **Stability Discharge:** {getattr(data, 'stability_discharge', 'N/A')}\n"
1012 |         electrodes_md += f"- **ID Charge:** {getattr(data, 'id_charge', 'N/A')}\n"
1013 |         electrodes_md += f"- **ID Discharge:** {getattr(data, 'id_discharge', 'N/A')}\n"
1014 |         electrodes_md += f"- **Host Structure:** {getattr(data, 'host_structure', 'N/A')}\n"
1015 |         
1016 |         # Add adjacent pairs information
1017 |         adj_pairs = getattr(data, 'adj_pairs', [])
1018 |         if adj_pairs:
1019 |             electrodes_md += f"\n### Adjacent Pairs:\n"
1020 |             for pair in adj_pairs:
1021 |                      # Use getattr instead of .get for pydantic/model objects
1022 |                 electrodes_md += f"- **Formula Charge:** {getattr(pair, 'formula_charge', 'N/A')}\n"
1023 |                 electrodes_md += f"- **Formula Discharge:** {getattr(pair, 'formula_discharge', 'N/A')}\n"
1024 |                 electrodes_md += f"- **Max Delta Volume:** {getattr(pair, 'max_delta_volume', 'N/A')}\n"
1025 |                 electrodes_md += f"- **Average Voltage:** {getattr(pair, 'average_voltage', 'N/A')}\n"
1026 |                 electrodes_md += f"- **Capacity Gravimetric:** {getattr(pair, 'capacity_grav', 'N/A')}\n"
1027 |                 electrodes_md += f"- **Capacity Volumetric:** {getattr(pair, 'capacity_vol', 'N/A')}\n"
1028 |                 electrodes_md += f"- **Energy Gravimetric:** {getattr(pair, 'energy_grav', 'N/A')}\n"
1029 |                 electrodes_md += f"- **Energy Volumetric:** {getattr(pair, 'energy_vol', 'N/A')}\n"
1030 |                 electrodes_md += f"- **Fraction A Charge:** {getattr(pair, 'fracA_charge', 'N/A')}\n"
1031 |                 electrodes_md += f"- **Fraction A Discharge:** {getattr(pair, 'fracA_discharge', 'N/A')}\n"
1032 |                 electrodes_md += f"- **Stability Charge:** {getattr(pair, 'stability_charge', 'N/A')}\n"
1033 |                 electrodes_md += f"- **Stability Discharge:** {getattr(pair, 'stability_discharge', 'N/A')}\n"
1034 |                 electrodes_md += f"- **ID Charge:** {getattr(pair, 'id_charge', 'N/A')}\n"
1035 |                 electrodes_md += f"- **ID Discharge:** {getattr(pair, 'id_discharge', 'N/A')}\n"
1036 |         
1037 |     return electrodes_md
1038 | 
1039 | 
1040 | 
1041 | @mcp.tool()
1042 | async def get_oxidation_states(
1043 |     material_id : str = Field(
1044 |         ...,
1045 |         description="Material ID for the material"
1046 |     ),
1047 |     formula: Optional[str] = Field(
1048 |         default=None, 
1049 |         description="Query by formula including anonymized formula or by including wild cards"
1050 |     )
1051 | ) -> str:
1052 |     """
1053 |     Get oxidation states for a given material ID or formula.
1054 |     
1055 |     This function retrieves the oxidation states of elements in a material
1056 |     from the Materials Project database. It can be queried by material ID or
1057 |     by formula, including anonymized formulas or wildcards.
1058 |     
1059 |     Args:
1060 |         material_id: The Materials Project ID of the material (e.g. 'mp-149')
1061 |         formula: Optional formula to query oxidation states (e.g. 'LiFeO2')
1062 |         
1063 |     Returns:
1064 |         str: A formatted markdown string containing the oxidation states information
1065 |         
1066 |     Example:
1067 |         >>> get_oxidation_states('mp-149')
1068 |         Returns oxidation states for silicon (mp-149)
1069 |     """
1070 |     logger.info(f"Fetching oxidation states for {material_id} with formula {formula}...")
1071 |     with _get_mp_rester() as mpr:
1072 |         oxidation_states = mpr.materials.oxidation_states.search(
1073 |             material_ids=material_id,
1074 |             formula=formula
1075 |         )
1076 | 
1077 |     if not oxidation_states:
1078 |         return f"No oxidation states found for {material_id}."
1079 | 
1080 |     oxidation_md = f"## Oxidation States for {material_id}\n\n"
1081 |     for idx, data in enumerate(oxidation_states):
1082 |         #oxidation_md = f"## Oxidation States for {material_id}\n\n"
1083 |         #oxidation_md += f"- **Material ID**: {material_id}\n"
1084 |         oxidation_md += f"- **Formula**: {getattr(data, "formula_pretty", "N/A")}\n\n"
1085 |         oxidation_md += f"- **formula_anonymous**: {getattr(data, "formula_anonymous", "N/A")}\n\n"
1086 |         oxidation_md += f"- **density** : {getattr(data, "density", "N/A")}\n\n"
1087 |         oxidation_md += f"- **volume**: {getattr(data, "volume", "N/A")}\n\n"
1088 |         oxidation_md += f"- **symmetry**: {getattr(data, "symmetry", "N/A")}\n\n"
1089 |         oxidation_md += f"- **nelements**: {getattr(data, "nelements", "N/A")}\n\n"
1090 |         oxidation_md += f"- **density_atomic**: {getattr(data, "density_atomic", "N/A")}\n\n"
1091 |         oxidation_md += f"- **property_name: {getattr(data, "property_name", "N/A")}\n\n"
1092 |         oxidation_md += f"- **structure**: {getattr(data, "structure", "N/A")}\n\n"
1093 |         oxidation_md += f"- **possible_species**: {getattr(data, "possible_species", "N/A")}\n\n"
1094 |         oxidation_md += f"- **possible_valances**: {getattr(data, "possible_valences", "N/A")}\n\n"
1095 |         oxidation_md += f"- **method**: {getattr(data, "method", "N/A")}\n\n"
1096 | 
1097 |     return oxidation_md
1098 | 
1099 | @mcp.tool()
1100 | async def construct_wulff_shape(
1101 |     material_id: str = Field(
1102 |         ..., 
1103 |         description="material ID of the material "
1104 |     )
1105 | ): 
1106 |     """
1107 |     Constructs a Wulff shape for a material.
1108 |     
1109 |     Args:
1110 |         material_id (str): Materials Project material_id, e.g. 'mp-123'.
1111 |     
1112 |     Returns: 
1113 |         object: image of the wulff shape 
1114 |         
1115 |     """
1116 |     logging.info(f"Getting Wulff shape for material: {material_id}")
1117 |     with _get_mp_rester() as mpr: 
1118 |         surface_data = mpr.surface_properties.search(material_id)
1119 | 
1120 | 
1121 |     if not surface_data: 
1122 |         return f"No surface data collected for wulff shape"
1123 |     
1124 |     try: 
1125 |         surface_energies = []
1126 |         miller_indices = []
1127 | 
1128 |         for surface in surface_data[0].surfaces: 
1129 |             miller_indices.append(surface.miller_index)
1130 |             surface_energies.append(surface.surface_energy)
1131 |         
1132 |         structure = mpr.get_structure_by_material_id(material_id=[material_id])
1133 |         
1134 |         wulff_shape = WulffShape(
1135 |             lattice=structure.lattice, 
1136 |             miller_list=miller_indices, 
1137 |             e_surf_list=surface_energies
1138 |         )
1139 | 
1140 |         # plot the shape 
1141 |         import io 
1142 |         import base64
1143 |         fig = wulff_shape.get_plot()
1144 |         #fig.suptitle(f"Wulff Shape\nVolume: {wulff_shape.volume:.3f} Ų", fontsize=14)
1145 |         buffer = io.BytesIO()
1146 |         fig.savefig(buffer, format='png', dpi=300, bbox_inches='tight')
1147 |         buffer.seek(0)
1148 |         image_base64 = base64.b64encode(buffer.read()).decode()
1149 |         # get the image
1150 |         #plt.close(fig) 
1151 |         return {
1152 |             "success": True,
1153 |             "material_id": material_id,
1154 |             "volume": round(wulff_shape.volume, 3),
1155 |             "surface_count": len(surface_energies),
1156 |             "miller_indices": miller_indices,
1157 |             "surface_energies": surface_energies,
1158 |             "image_base64": image_base64,
1159 |             "message": f"Wulff shape constructed for {material_id}"
1160 |         }
1161 | 
1162 |     except Exception as e: 
1163 |         logging.error(f"Error occurred constructing wulff shape: {e}")
1164 |         return f"No wulff shape construted for material: {material_id}"
1165 | 
1166 | 
1167 | 
1168 | @mcp.resource(uri="materials_docs://{filename}")
1169 | async def get_materials_docs(
1170 |     filename: str
1171 | ) -> str: 
1172 |     """
1173 |     Retrieve docs from the markdown folder
1174 |     
1175 |     Args:
1176 |         filename (str): The name of the file to retrieve from the folder eg. apidocs or docsmaterials
1177 |         
1178 |     """
1179 |     from utils.utility_functions import MarkdownResourceManager, MARKDOWN_FOLDER
1180 |     logger.info(f"Retrieving documentation file: {filename}")
1181 |     resource_manager = MarkdownResourceManager(MARKDOWN_FOLDER)
1182 |     try: 
1183 |         resource_manager.load_files()
1184 |         if filename not in resource_manager.files:
1185 |             logger.error(f"File {filename} not found in the documentation resources.")
1186 |             return f"File {filename} not found in the documentation resources."
1187 |         file_content = resource_manager.files[filename].content
1188 |         print(file_content)
1189 |         return file_content
1190 |     except Exception as e:
1191 |         logger.error(f"Error retrieving documentation file {filename}: {e}")
1192 |         return f"Error retrieving documentation file {filename}: {e}"
1193 | 
1194 | 
1195 | @mcp.prompt(name="get_electronic_bandstructure")
1196 | async def BandStructurePrompt() -> str:
1197 |    """Prompt for retrieving electronic band structure data."""
1198 |    from utils.prompts_templates import ElectronicBandStructurePrompt
1199 |     # I want to return 
1200 |    return ElectronicBandStructurePrompt.template
1201 | 
1202 | 
1203 | @mcp.tool()
1204 | async def get_doi(
1205 |     material_id: str = Field(
1206 |         ..., 
1207 |         description="Material ID of the material"
1208 |     )
1209 | ) -> str: 
1210 |     """
1211 |     Get DOI  and bibTex reference for a given material ID.
1212 |     
1213 |     Args:
1214 |         material_id (str): The Materials Project ID of the material (e.g. 'mp-149')
1215 |     Returns:
1216 |         str: A formatted markdown string containing the DOI and bibTex reference
1217 |     """
1218 |     logger.info("Fetching DOI for material ID {material_id}...")
1219 |     with _get_mp_rester() as mpr:
1220 |         doi_data = mpr.doi.get_data_by_id(material_id)
1221 | 
1222 |     if not doi_data:
1223 |         return f"No DOI found for material ID {material_id}."
1224 |     
1225 |     dos_markdown = f"## DOI Information for {material_id}\n\n"
1226 |     dos_markdown += f"- **DOI:** {doi_data.doi} \n\n"
1227 |     dos_markdown += f"- **BibTeX:**\n```\n{doi_data.bibtex}\n```\n"
1228 |     
1229 |     return dos_markdown
1230 | 
1231 | 
1232 | 
1233 | 
1234 | 
1235 | 
1236 | 
1237 | 
1238 | if __name__ == "__main__":
1239 |     # Initialize and run the server
1240 |     mcp.run(transport='stdio')
```