pdbe-mcp-server 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,16 @@
1
+ from pathlib import Path
2
+ from typing import cast
3
+
4
+ from omegaconf import DictConfig, OmegaConf
5
+
6
+
7
+ def get_config() -> DictConfig:
8
+ """
9
+ Load and return configuration from config.yaml using OmegaConf.
10
+
11
+ Returns:
12
+ DictConfig: Configuration object from config.yaml
13
+ """
14
+ config_path: Path = Path(__file__).parent / "config.yaml"
15
+ config = cast(DictConfig, OmegaConf.load(config_path))
16
+ return config
@@ -0,0 +1,291 @@
1
+ import json
2
+ from typing import Any
3
+ from urllib.parse import urljoin
4
+
5
+ import mcp.types as types
6
+ import requests
7
+ from omegaconf import DictConfig
8
+
9
+ from pdbe_mcp_server import get_config
10
+ from pdbe_mcp_server.utils import HTTPClient
11
+
12
+ conf: DictConfig = get_config()
13
+
14
+
15
+ class OpenAPIToMCPGenerator:
16
+ def __init__(self, openapi_url: str) -> None:
17
+ """
18
+ Initialize the generator with an OpenAPI JSON URL.
19
+
20
+ Args:
21
+ openapi_url: URL to the OpenAPI JSON specification
22
+ """
23
+ self.openapi_url: str = openapi_url
24
+ self.openapi_spec: dict[str, Any] = {}
25
+ self.base_url: str = str(conf.api.base_url)
26
+ self.tools: list[dict[str, Any]] = []
27
+
28
+ def load_openapi_spec(self) -> dict[str, Any]:
29
+ """
30
+ Load the OpenAPI specification from the remote URL.
31
+
32
+ Returns:
33
+ The parsed OpenAPI specification as a dictionary
34
+ """
35
+ try:
36
+ spec = HTTPClient.get(self.openapi_url)
37
+ self.openapi_spec = spec
38
+ return spec
39
+
40
+ except requests.HTTPError as e:
41
+ raise Exception(
42
+ f"Failed to load OpenAPI spec from {self.openapi_url}: {e}"
43
+ ) from e
44
+
45
+ def _convert_openapi_type_to_json_schema(
46
+ self, param_schema: dict[str, Any]
47
+ ) -> dict[str, Any]:
48
+ """
49
+ Convert OpenAPI parameter schema to JSON Schema format.
50
+
51
+ Args:
52
+ param_schema: OpenAPI parameter schema
53
+
54
+ Returns:
55
+ JSON Schema compatible dictionary
56
+ """
57
+ json_schema = {}
58
+
59
+ # Map OpenAPI types to JSON Schema types
60
+ if "type" in param_schema:
61
+ json_schema["type"] = param_schema["type"]
62
+
63
+ if "description" in param_schema:
64
+ json_schema["description"] = param_schema["description"]
65
+
66
+ if "enum" in param_schema:
67
+ json_schema["enum"] = param_schema["enum"]
68
+
69
+ if "default" in param_schema:
70
+ json_schema["default"] = param_schema["default"]
71
+
72
+ if "format" in param_schema:
73
+ json_schema["format"] = param_schema["format"]
74
+
75
+ # Handle array types
76
+ if param_schema.get("type") == "array" and "items" in param_schema:
77
+ json_schema["items"] = self._convert_openapi_type_to_json_schema(
78
+ param_schema["items"]
79
+ )
80
+
81
+ return json_schema
82
+
83
+ def _extract_parameters(
84
+ self, path_params: list[dict[str, Any]], query_params: list[dict[str, Any]]
85
+ ) -> tuple[dict[str, Any], list[str]]:
86
+ """
87
+ Extract and convert parameters to JSON Schema format.
88
+
89
+ Args:
90
+ path_params: List of path parameters
91
+ query_params: List of query parameters
92
+
93
+ Returns:
94
+ JSON Schema properties and required fields
95
+ """
96
+ properties = {}
97
+ required = []
98
+
99
+ # Process path parameters
100
+ for param in path_params:
101
+ param_name = param["name"]
102
+ properties[param_name] = self._convert_openapi_type_to_json_schema(
103
+ param.get("schema", {})
104
+ )
105
+ schema = param.get("schema", {})
106
+ description = schema.get("description", "")
107
+ for key in schema:
108
+ description += f"\n{key}: {schema[key]}"
109
+
110
+ properties[param_name]["description"] = description
111
+ if param.get("required", False):
112
+ required.append(param_name)
113
+
114
+ # Process query parameters
115
+ for param in query_params:
116
+ param_name = param["name"]
117
+ properties[param_name] = self._convert_openapi_type_to_json_schema(
118
+ param.get("schema", {})
119
+ )
120
+ if param.get("description"):
121
+ properties[param_name]["description"] = param["description"]
122
+ if param.get("required", False):
123
+ required.append(param_name)
124
+
125
+ return properties, required
126
+
127
+ def list_tools(self) -> list[types.Tool]:
128
+ """
129
+ Generate MCP tools from the OpenAPI specification.
130
+
131
+ Returns:
132
+ List of MCP Tool objects
133
+ """
134
+ if not self.openapi_spec:
135
+ self.load_openapi_spec()
136
+
137
+ tools = []
138
+ paths = self.openapi_spec.get("paths", {})
139
+
140
+ for path, path_item in paths.items():
141
+ for method, operation in path_item.items():
142
+ # Skip POST methods and non-HTTP methods
143
+ if method.upper() == "POST" or method.startswith("x-"):
144
+ continue
145
+
146
+ # skip APIs which are not enabled for MCP
147
+ if not operation.get("enableMCP") or not operation["enableMCP"]:
148
+ continue
149
+
150
+ # Generate tool name from operationId or path/method
151
+ if "operationId" in operation:
152
+ tool_name = operation["operationId"]
153
+ else:
154
+ # Create tool name from path and method
155
+ clean_path = (
156
+ path.replace("/", "_")
157
+ .replace("{", "")
158
+ .replace("}", "")
159
+ .strip("_")
160
+ )
161
+ tool_name = f"{method}_{clean_path}".replace("__", "_")
162
+
163
+ # trim tool name to at most 60 characters
164
+ tool_name = tool_name[:60]
165
+
166
+ # Get description
167
+ description = operation.get("description", operation.get("summary"))
168
+
169
+ # Extract parameters
170
+ parameters = operation.get("parameters", [])
171
+ path_params = [p for p in parameters if p.get("in") == "path"]
172
+ query_params = [p for p in parameters if p.get("in") == "query"]
173
+
174
+ properties, required = self._extract_parameters(
175
+ path_params, query_params
176
+ )
177
+
178
+ # Create input schema
179
+ input_schema = {
180
+ "type": "object",
181
+ "properties": properties,
182
+ "additionalProperties": False,
183
+ }
184
+
185
+ if required:
186
+ input_schema["required"] = required
187
+
188
+ # Create tool
189
+ tool = types.Tool(
190
+ name=tool_name,
191
+ description=description,
192
+ inputSchema=input_schema,
193
+ annotations=types.ToolAnnotations(
194
+ title=description,
195
+ destructiveHint=False,
196
+ readOnlyHint=True,
197
+ idempotentHint=False,
198
+ ),
199
+ )
200
+ tools.append(tool)
201
+
202
+ # Store tool metadata for call_tool function
203
+ self.tools.append(
204
+ {
205
+ "name": tool_name,
206
+ "method": method.upper(),
207
+ "path": path,
208
+ "path_params": [p["name"] for p in path_params],
209
+ "query_params": [p["name"] for p in query_params],
210
+ }
211
+ )
212
+
213
+ return tools
214
+
215
+ def call_tool(
216
+ self, name: str, arguments: dict[str, Any]
217
+ ) -> list[types.TextContent | types.ImageContent | types.EmbeddedResource]:
218
+ """
219
+ Execute a tool call by making the appropriate API request.
220
+
221
+ Args:
222
+ name: Tool name to execute
223
+ arguments: Arguments for the tool
224
+
225
+ Returns:
226
+ List of TextContent with the API response
227
+ """
228
+ # Find the tool metadata
229
+ tool_info = None
230
+ for tool in self.tools:
231
+ if tool["name"] == name:
232
+ tool_info = tool
233
+ break
234
+
235
+ if not tool_info:
236
+ raise ValueError(f"Unknown tool: {name}")
237
+
238
+ # Build the URL
239
+ url_path = tool_info["path"]
240
+
241
+ # Replace path parameters
242
+ for param_name in tool_info["path_params"]:
243
+ if param_name not in arguments:
244
+ raise ValueError(f"Missing required path parameter: {param_name}")
245
+ url_path = url_path.replace(f"{{{param_name}}}", str(arguments[param_name]))
246
+
247
+ # Build full URL
248
+ full_url = urljoin(self.base_url, url_path.lstrip("/"))
249
+
250
+ # Build path parameters
251
+ path_params = {}
252
+ for param_name in tool_info["path_params"]:
253
+ if param_name in arguments:
254
+ path_params[param_name] = arguments[param_name]
255
+
256
+ try:
257
+ if tool_info["method"] == "GET":
258
+ data = HTTPClient.get(full_url)
259
+ elif tool_info["method"] == "POST":
260
+ data = HTTPClient.post(full_url)
261
+ else:
262
+ # unsupported method, raise an error
263
+ raise ValueError(f"Unsupported method: {tool_info['method']}")
264
+
265
+ result_text = json.dumps(data, indent=2)
266
+ return [types.TextContent(type="text", text=result_text)]
267
+
268
+ except requests.RequestException as e:
269
+ error_message = f"API request failed: {e}"
270
+ if hasattr(e, "response") and e.response is not None:
271
+ error_message += f"\nStatus Code: {e.response.status_code}"
272
+ error_message += f"\nResponse: {e.response.text}"
273
+
274
+ return [types.TextContent(type="text", text=error_message)]
275
+
276
+
277
+ def create_mcp_tools_from_openapi(
278
+ openapi_url: str,
279
+ ) -> tuple[OpenAPIToMCPGenerator, list[types.Tool]]:
280
+ """
281
+ Main function to create MCP tools from an OpenAPI specification.
282
+
283
+ Args:
284
+ openapi_url: URL to the OpenAPI JSON specification
285
+
286
+ Returns:
287
+ Tuple of (generator_instance, list_of_tools)
288
+ """
289
+ generator = OpenAPIToMCPGenerator(openapi_url)
290
+ tools = generator.list_tools()
291
+ return generator, tools
@@ -0,0 +1,8 @@
1
+ api:
2
+ openapi_url: "https://www.ebi.ac.uk/pdbe/api/v2/openapi.json"
3
+ base_url: "https://www.ebi.ac.uk/pdbe/api/v2/"
4
+ graph:
5
+ schema_url: "https://www.ebi.ac.uk/pdbe/static/files/graph_schema.json"
6
+ search:
7
+ schema_url: "https://www.ebi.ac.uk/pdbe/static/files/search_schema.json"
8
+ search_api: "https://www.ebi.ac.uk/pdbe/search/pdb/select?wt=json"
@@ -0,0 +1,224 @@
1
+ from typing import Any
2
+
3
+ import mcp.types as types
4
+ from omegaconf import DictConfig
5
+
6
+ from pdbe_mcp_server import get_config
7
+ from pdbe_mcp_server.utils import HTMLStripper, HTTPClient
8
+
9
+ conf: DictConfig = get_config()
10
+
11
+
12
+ class GraphTools:
13
+ """
14
+ A class to handle PDBe graph-related operations.
15
+ """
16
+
17
+ def __init__(self) -> None:
18
+ """
19
+ Initialize the GraphTools object, load the graph schema, and prepare node and edge lists.
20
+ """
21
+ self.graph_schema: dict[str, Any] = self._get_graph_schema()
22
+ self.node_dict: dict[Any, str] = {}
23
+ self.nodes: list[dict[str, Any]] = self.get_nodes()
24
+ self.edges: list[dict[str, Any]] = self.get_edges()
25
+
26
+ def get_pdbe_graph_nodes_tool(self) -> types.Tool:
27
+ return types.Tool(
28
+ name="pdbe_graph_nodes",
29
+ description="""
30
+ Retrieves metadata about all node types (also known as "labels") defined in the PDBe (PDBe-KB) graph database schema.
31
+ This tool can be used to understand the different types of entities represented in the PDBe graph database, along with
32
+ their properties and descriptions and then can be used to explore the graph more effectively by writing Cypher queries.
33
+ This tool returns detailed information about each node label in the graph database. For every node label, it includes:
34
+ - The label name (e.g., 'ValAngleOutlier', 'Antibody', 'Atom')
35
+ - A human-readable description of the node type
36
+ - A list of properties/parameters associated with this node type
37
+ - For each property: the name and a brief description
38
+
39
+ Expected Output Format (text):
40
+ Label: ValAngleOutlier
41
+ Description: Bond angle outliers based on wwPDB validation data.
42
+ Properties:
43
+ - ATOM0/1/2/3: Names of atoms involved in the angle which is an outlier.
44
+ - MEAN: The ideal value of the bond angle.
45
+ - OBS: The observed value of the bond angle.
46
+
47
+ (Additional node labels follow the same format...)
48
+ """,
49
+ inputSchema={
50
+ "type": "object",
51
+ "properties": {},
52
+ "additionalProperties": False,
53
+ },
54
+ annotations=types.ToolAnnotations(
55
+ title="Get PDBe Graph Nodes",
56
+ destructiveHint=False,
57
+ readOnlyHint=True,
58
+ idempotentHint=True,
59
+ ),
60
+ )
61
+
62
+ def get_pdbe_graph_edges_tool(self) -> types.Tool:
63
+ return types.Tool(
64
+ name="pdbe_graph_edges",
65
+ description="""
66
+ Retrieves metadata about all relationship types (edges) defined in the PDBe (PDBe-KB) graph database schema.
67
+ This tool can be used to understand the different types of relationships represented in the PDBe graph database, along with
68
+ their start and end nodes, properties and descriptions and then can be used to explore the graph more effectively by writing Cypher queries.
69
+ This tool returns detailed information about each relationship (edge) in the graph. For every relationship type, it includes:
70
+ - The relationship label (e.g., 'HAS_OUTLIER', 'CONNECTS_TO')
71
+ - A human-readable description of the relationship
72
+ - The 'from' node label and 'to' node label, defining the direction and connectivity
73
+ - A list of properties associated with the relationship
74
+ - For each property: the name and a brief description
75
+
76
+ Expected Output Format (text):
77
+ Label: HAS_OUTLIER
78
+ Description: Indicates a structure has validation outliers
79
+ From: Structure
80
+ To: ValAngleOutlier
81
+ Properties:
82
+ - since: The date when the outlier was detected
83
+ - severity: The severity level of the outlier
84
+
85
+ (Additional relationship types follow the same format...)
86
+ """,
87
+ inputSchema={
88
+ "type": "object",
89
+ "properties": {},
90
+ "additionalProperties": False,
91
+ },
92
+ annotations=types.ToolAnnotations(
93
+ title="Get PDBe Graph Edges",
94
+ destructiveHint=False,
95
+ readOnlyHint=True,
96
+ idempotentHint=True,
97
+ ),
98
+ )
99
+
100
+ def _get_graph_schema(self) -> dict[str, Any]:
101
+ """
102
+ Retrieve the PDBe graph schema from the remote server and return it as a dictionary.
103
+ """
104
+ return HTTPClient.get(str(conf.graph.schema_url))
105
+
106
+ def get_nodes(self) -> list[dict[str, Any]]:
107
+ """
108
+ Get the nodes from the graph schema, clean up their descriptions and titles, and store them in a list.
109
+ Also populates the node_dict for quick label lookup.
110
+
111
+ Returns:
112
+ List of node dictionaries.
113
+ """
114
+ nodes = []
115
+ for node in self.graph_schema.get("nodes", []):
116
+ # clean up the node data
117
+ node["description"] = HTMLStripper.strip_tags(node.get("description", ""))
118
+ node["title"] = HTMLStripper.strip_tags(node.get("title", ""))
119
+ for prop in node.get("properties", []):
120
+ prop["value"] = HTMLStripper.strip_tags(prop.get("value", ""))
121
+ nodes.append(node)
122
+
123
+ # store the node label in a dictionary for quick access
124
+ self.node_dict[node.get("id")] = node["label"]
125
+
126
+ return nodes
127
+
128
+ def get_edges(self) -> list[dict[str, Any]]:
129
+ """
130
+ Get the edges from the graph schema, clean up their descriptions and titles, and store them in a list.
131
+
132
+ Returns:
133
+ List of edge dictionaries.
134
+ """
135
+ edges = []
136
+ for edge in self.graph_schema.get("edges", []):
137
+ # clean up the edge data
138
+ edge["description"] = HTMLStripper.strip_tags(edge.get("description", ""))
139
+ edge["title"] = HTMLStripper.strip_tags(edge.get("title", ""))
140
+ for prop in edge.get("properties", []):
141
+ prop["value"] = HTMLStripper.strip_tags(prop.get("value", ""))
142
+ edges.append(edge)
143
+
144
+ return edges
145
+
146
+ def format_nodes(self) -> str:
147
+ """
148
+ Format the nodes as a string for LLM or human-readable output.
149
+
150
+ Returns:
151
+ A formatted string listing all nodes and their properties.
152
+ """
153
+ return "\n\n".join(
154
+ f"Label: {node['label']}\nDescription: {node.get('description', '')}\n"
155
+ + (
156
+ "Properties:\n"
157
+ + "\n".join(
158
+ f" - {prop.get('name', '')}: {prop.get('value', '')}"
159
+ for prop in node.get("properties", [])
160
+ )
161
+ if node.get("properties")
162
+ else "Properties: None"
163
+ )
164
+ for node in self.nodes
165
+ )
166
+
167
+ def format_edges(self) -> str:
168
+ """
169
+ Format the edges as a string for LLM or human-readable output.
170
+
171
+ Returns:
172
+ A formatted string listing all edges and their properties.
173
+ """
174
+ return "\n\n".join(
175
+ f"Label: {edge['label']}\n"
176
+ f"Description: {edge.get('description', '')}\n"
177
+ f"From: {self.node_dict.get(edge.get('from'), edge.get('from'))}\n"
178
+ f"To: {self.node_dict.get(edge.get('to'), edge.get('to'))}\n"
179
+ + (
180
+ "Properties:\n"
181
+ + "\n".join(
182
+ f" - {prop.get('name', '')}: {prop.get('value', '')}"
183
+ for prop in edge.get("properties", [])
184
+ )
185
+ if edge.get("properties")
186
+ else "Properties: None"
187
+ )
188
+ for edge in self.edges
189
+ )
190
+
191
+ def get_node_by_label(self, node_label: str) -> str | None:
192
+ """
193
+ Get a formatted string for a node by its label.
194
+
195
+ Args:
196
+ node_label: The label of the node to search for.
197
+
198
+ Returns:
199
+ A formatted string with node information, or None if not found.
200
+ """
201
+ for node in self.nodes:
202
+ if node.get("label") == node_label:
203
+ # format the node
204
+ return f"""
205
+ Label: {node_label}
206
+ Description: {node.get("description")}
207
+ """
208
+
209
+ return None
210
+
211
+ def get_edge_by_label(self, edge_label: str) -> dict[str, Any] | None:
212
+ """
213
+ Get an edge dictionary by its label.
214
+
215
+ Args:
216
+ edge_label: The label of the edge to search for.
217
+
218
+ Returns:
219
+ The edge dictionary if found, otherwise None.
220
+ """
221
+ for edge in self.edges:
222
+ if edge.get("label") == edge_label:
223
+ return edge
224
+ return None
@@ -0,0 +1,45 @@
1
+ from typing import Any
2
+
3
+
4
+ def format_graph_info(graph_data: dict[str, Any]) -> str:
5
+ output = []
6
+
7
+ # Format Nodes
8
+ output.append("Nodes\n" + "=" * 5 + "\n")
9
+
10
+ for node in graph_data.get("nodes", []):
11
+ output.append(f"Label: {node['label']}")
12
+ output.append(f"Description: {node['description']}")
13
+ output.append("Properties:")
14
+
15
+ if node.get("properties"):
16
+ for prop in node["properties"]:
17
+ name = prop.get("name", "<No name>")
18
+ value = prop.get("value", "<No description>")
19
+ output.append(f" - {name}: {value}")
20
+ else:
21
+ output.append(" None")
22
+
23
+ output.append("") # Blank line between nodes
24
+
25
+ # Format Edges (Relationships)
26
+ output.append("Relationships\n" + "=" * 12 + "\n")
27
+
28
+ for edge in graph_data.get("edges", []):
29
+ output.append(f"Label: {edge['label']}")
30
+ output.append(f"Description: {edge['description']}")
31
+ output.append(f"From Node ID: {edge['from']}")
32
+ output.append(f"To Node ID: {edge['to']}")
33
+ output.append("Properties:")
34
+
35
+ if edge.get("properties"):
36
+ for prop in edge["properties"]:
37
+ name = prop.get("name", "<No name>")
38
+ value = prop.get("value", "<No description>")
39
+ output.append(f" - {name}: {value}")
40
+ else:
41
+ output.append(" None")
42
+
43
+ output.append("") # Blank line between edges
44
+
45
+ return "\n".join(output)
@@ -0,0 +1,2 @@
1
+ # Marker file for PEP 561.
2
+ # This package provides inline type annotations.