Skip to content

Commit a8584e4

Browse files
draft openai compatible mcp
1 parent ffb8fbc commit a8584e4

4 files changed

Lines changed: 150 additions & 3 deletions

File tree

mp_api/mcp/_schemas.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
"""Define auxiliary schemas used by some LLMs."""
2+
3+
from typing import Any
4+
from pydantic import BaseModel, Field, model_validator
5+
6+
from mp_api.client.core.utils import validate_ids
7+
8+
class OpenAIResult(BaseModel):
9+
"""Schematize result for OpenAI support."""
10+
11+
id : str
12+
title : str | None = None
13+
text : str | None = None
14+
url : str | None = None
15+
metadata : dict[str,str] | None = None
16+
17+
@model_validator(mode="before")
18+
def set_url(cls, config : Any) -> Any:
19+
"""Set default Materials Project URL and title."""
20+
21+
formatted_mpid = validate_ids([config.get("id")])[0]
22+
if not config.get("title"):
23+
config["title"] = formatted_mpid
24+
25+
if not config.get("url"):
26+
config["url"] = (
27+
"https://next-gen.materialsproject.org/materials/"
28+
f"{formatted_mpid}"
29+
)
30+
return config
31+
32+
class OpenAISearchOutput(BaseModel):
33+
"""Schematize data for OpenAI support."""
34+
35+
results : list[OpenAIResult] = Field([])

mp_api/mcp/mp_mcp.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,27 @@
66
import httpx
77
from fastmcp import FastMCP
88

9-
from mp_api.mcp.tools import MPMcpTools
9+
from mp_api.mcp.tools import MPMcpTools, MPOpenAIMcpTools
1010
from mp_api.mcp.utils import _NeedsMPClient
1111

12+
MCP_SERVER_INSTRUCTIONS = """
13+
This MCP server defines search and document retrieval capabilities
14+
for data in the Materials Project.
15+
Use the search tool to find relevant documents based on materials
16+
keywords.
17+
Then use the fetch tool to retrieve complete materials summary information.
18+
"""
19+
20+
def get_openai_compat_mcp() -> FastMCP:
21+
"""Create MCP for compatibility with OpenAI models."""
22+
mp_mcp = FastMCP(
23+
"Materials_Project_MCP",
24+
instructions=MCP_SERVER_INSTRUCTIONS,
25+
)
26+
openai_compat_tools = MPOpenAIMcpTools()
27+
for k in {"search","fetch"}:
28+
mp_mcp.tool(getattr(openai_compat_tools,k))
29+
return mp_mcp
1230

1331
def get_mcp() -> FastMCP:
1432
"""MCP with finer depth of control over tool names."""

mp_api/mcp/server.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
"""Run MCP."""
22
from __future__ import annotations
33

4-
from mp_api.mcp.mp_mcp import get_mcp
4+
from mp_api.mcp.mp_mcp import get_mcp, get_openai_compat_mcp
55

6-
mcp = get_mcp()
6+
mcp = get_openai_compat_mcp()
77

88
if __name__ == "__main__":
99
mcp.run()

mp_api/mcp/tools.py

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,101 @@
2828
from pymatgen.electronic_structure.core import OrbitalType, Spin
2929
from pymatgen.entries.computed_entries import ComputedEntry
3030

31+
from mp_api.client.core import MPRestError
3132
from mp_api.mcp.utils import _NeedsMPClient
33+
from mp_api.mcp._schemas import OpenAISearchOutput, OpenAIResult
34+
35+
class MPOpenAIMcpTools(_NeedsMPClient):
36+
"""Define OpenAI-specific MCP for the Materials Project API."""
37+
38+
def search(self, query : str) -> OpenAISearchOutput:
39+
"""Define OpenAI compatible search.
40+
41+
Search through the autogenerated robocrystallographer
42+
descriptions of materials to return lists of likely
43+
matching materials.
44+
45+
Args:
46+
query (str) : A natural language query of material keywords.
47+
It is assumed that the query contains comma-delimited keywords.
48+
49+
Returns:
50+
OpenAISearchOutput, a dict of `results` each with structure
51+
mp_api.mcp._schemas.OpenAIResult
52+
"""
53+
return OpenAISearchOutput(
54+
retults = [
55+
OpenAIResult(
56+
id = doc["material_id"],
57+
text = doc["description"]
58+
)
59+
for doc in self.client.robocrys.search(query.split(","))
60+
]
61+
)
62+
63+
def fetch(self, idx : str) -> OpenAIResult:
64+
"""Retrieve complete material information by Materials Project ID.
65+
66+
Args:
67+
idx (str) : A Materials Project ID.
68+
Should be an integer prefixed by `mp-`, ex: "mp-149", "mp-13"
69+
70+
Returns:
71+
OpenAIResult : Complete document with id, title, robocrys
72+
autogenerated description, URL, and metadata derived from
73+
the materials summary collection.
74+
75+
If no data about the particular id is available, returns a
76+
OpenAIResult with only the id field populated.
77+
78+
Raises:
79+
MPRestError: If no identifier is specified
80+
"""
81+
if not isinstance(idx,str):
82+
raise MPRestError(
83+
f"Unknown {idx=}. Should be an integer prefixed by `mp-`, ex: "
84+
"'mp-1', 'mp-1010101'"
85+
)
86+
87+
robo_desc : str | None = None
88+
if len(
89+
robo_docs := self.client.robocrys.search_docs(
90+
material_ids=[idx]
91+
)
92+
) > 0:
93+
robo_desc = robo_docs[0]["description"]
94+
95+
if not robo_desc:
96+
return OpenAIResult(id = idx)
97+
98+
metadata : dict[str,str] | None = None
99+
if len(
100+
summary_docs := self.client.summary.search(
101+
material_ids=[idx]
102+
)
103+
) > 0:
104+
# Try to avoid more nested fields, just provide things with
105+
# simple str or numeric type
106+
metadata = {
107+
k : str(summary_docs[0][k])
108+
for k in self.client.summary.document_model.model_fields
109+
if isinstance(summary_docs[0][k], str | int | float)
110+
}
111+
112+
# Augment with experimental database id information
113+
if summary_docs[0]["database_IDs"]:
114+
metadata.update(
115+
{
116+
f"linked_{database}_ids" : ", ".join(matched_ids)
117+
for database, matched_ids in summary_docs[0]["database_IDs"].items()
118+
}
119+
)
120+
121+
return OpenAIResult(
122+
id = idx,
123+
text = robo_desc,
124+
metadata = metadata
125+
)
32126

33127

34128
class MPMcpTools(_NeedsMPClient):

0 commit comments

Comments
 (0)