gfp-mcp 0.1.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,54 @@
1
+ """Configuration management for MCP standalone server."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from typing import Final
7
+
8
+ __all__ = ["MCPConfig"]
9
+
10
+
11
+ class MCPConfig:
12
+ """Configuration for MCP standalone server.
13
+
14
+ Manages environment variables and default settings for the MCP server
15
+ that proxies requests to the FastAPI backend.
16
+ """
17
+
18
+ # FastAPI base URL (default: http://localhost:8787)
19
+ API_URL: Final[str] = os.getenv("GFP_API_URL", "http://localhost:8787")
20
+
21
+ # Timeout for tool calls in seconds (default: 300 = 5 minutes)
22
+ TIMEOUT: Final[int] = int(os.getenv("GFP_MCP_TIMEOUT", "300"))
23
+
24
+ # Enable debug logging
25
+ DEBUG: Final[bool] = os.getenv("GFP_MCP_DEBUG", "false").lower() in (
26
+ "true",
27
+ "1",
28
+ "yes",
29
+ )
30
+
31
+ # Retry configuration
32
+ MAX_RETRIES: Final[int] = 3
33
+ RETRY_BACKOFF: Final[float] = 0.5 # Initial backoff in seconds
34
+
35
+ @classmethod
36
+ def get_api_url(cls, override: str | None = None) -> str:
37
+ """Get the FastAPI base URL.
38
+
39
+ Args:
40
+ override: Optional URL to override the environment variable
41
+
42
+ Returns:
43
+ The API base URL
44
+ """
45
+ return override or cls.API_URL
46
+
47
+ @classmethod
48
+ def get_timeout(cls) -> int:
49
+ """Get the timeout for tool calls.
50
+
51
+ Returns:
52
+ Timeout in seconds
53
+ """
54
+ return cls.TIMEOUT
@@ -0,0 +1,286 @@
1
+ """Mappings from MCP tools to FastAPI endpoints.
2
+
3
+ This module defines how MCP tool arguments are transformed into HTTP
4
+ requests to the FastAPI backend, and how responses are transformed back.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from collections.abc import Callable
10
+ from typing import Any
11
+
12
+ __all__ = [
13
+ "TOOL_MAPPINGS",
14
+ "get_mapping",
15
+ "transform_request",
16
+ "transform_response",
17
+ ]
18
+
19
+
20
+ class EndpointMapping:
21
+ """Configuration for mapping an MCP tool to a FastAPI endpoint."""
22
+
23
+ def __init__(
24
+ self,
25
+ method: str,
26
+ path: str,
27
+ request_transformer: Callable[[dict[str, Any]], dict[str, Any]] | None = None,
28
+ response_transformer: Callable[[Any], Any] | None = None,
29
+ ) -> None:
30
+ """Initialize endpoint mapping.
31
+
32
+ Args:
33
+ method: HTTP method (GET, POST, etc.)
34
+ path: API endpoint path
35
+ request_transformer: Optional function to transform MCP args to
36
+ HTTP params
37
+ response_transformer: Optional function to transform HTTP response
38
+ to MCP format
39
+ """
40
+ self.method = method
41
+ self.path = path
42
+ self.request_transformer = request_transformer or (lambda x: x)
43
+ self.response_transformer = response_transformer or (lambda x: x)
44
+
45
+
46
+ def _build_cell_request(args: dict[str, Any]) -> dict[str, Any]:
47
+ """Transform build_cell MCP args to FastAPI params.
48
+
49
+ Args:
50
+ args: MCP tool arguments
51
+
52
+ Returns:
53
+ Dict with 'params' key for query parameters
54
+ """
55
+ return {
56
+ "params": {
57
+ "name": args["name"],
58
+ "with_metadata": args.get("with_metadata", True),
59
+ "register": args.get("register", True),
60
+ }
61
+ }
62
+
63
+
64
+ def _build_cells_request(args: dict[str, Any]) -> dict[str, Any]:
65
+ """Transform build_cells MCP args to FastAPI params.
66
+
67
+ Args:
68
+ args: MCP tool arguments
69
+
70
+ Returns:
71
+ Dict with 'params' key for query parameters and 'json_data' for body
72
+ """
73
+ return {
74
+ "params": {
75
+ "with_metadata": args.get("with_metadata", True),
76
+ "register": args.get("register", True),
77
+ },
78
+ "json_data": args["names"],
79
+ }
80
+
81
+
82
+ def _list_cells_response(response: Any) -> dict[str, Any]:
83
+ """Transform list_cells response to MCP format.
84
+
85
+ Args:
86
+ response: FastAPI response (list of cell names)
87
+
88
+ Returns:
89
+ Formatted response with cell names
90
+ """
91
+ if isinstance(response, list):
92
+ return {"cells": response, "count": len(response)}
93
+ return response
94
+
95
+
96
+ def _get_cell_info_request(args: dict[str, Any]) -> dict[str, Any]:
97
+ """Transform get_cell_info MCP args to FastAPI params.
98
+
99
+ Args:
100
+ args: MCP tool arguments
101
+
102
+ Returns:
103
+ Dict with 'params' key for query parameters
104
+ """
105
+ return {"params": {"name": args["name"]}}
106
+
107
+
108
+ def _download_gds_request(args: dict[str, Any]) -> dict[str, Any]:
109
+ """Transform download_gds MCP args to FastAPI path.
110
+
111
+ Args:
112
+ args: MCP tool arguments
113
+
114
+ Returns:
115
+ Dict with modified 'path' for the endpoint
116
+ """
117
+ path = args["path"]
118
+ # The path template in FastAPI is /api/download/{path:path}.gds
119
+ # We need to construct the full path
120
+ return {"path": f"/api/download/{path}.gds"}
121
+
122
+
123
+ def _download_gds_response(response: Any) -> dict[str, Any]:
124
+ """Transform download_gds response to MCP format.
125
+
126
+ Args:
127
+ response: FastAPI response (file path or error)
128
+
129
+ Returns:
130
+ Formatted response with file path
131
+ """
132
+ # If response is a string (text), it's likely the file content or path
133
+ if isinstance(response, str):
134
+ return {"status": "success", "message": response}
135
+ return response
136
+
137
+
138
+ def _check_drc_request(args: dict[str, Any]) -> dict[str, Any]:
139
+ """Transform check_drc MCP args to FastAPI params.
140
+
141
+ Args:
142
+ args: MCP tool arguments
143
+
144
+ Returns:
145
+ Dict with 'json_data' key for request body
146
+ """
147
+ json_data: dict[str, Any] = {"path": args["path"]}
148
+
149
+ # Add optional parameters if provided
150
+ if "pdk" in args and args["pdk"]:
151
+ json_data["pdk"] = args["pdk"]
152
+ if "process" in args and args["process"]:
153
+ json_data["process"] = args["process"]
154
+ if "timeout" in args and args["timeout"]:
155
+ json_data["timeout"] = args["timeout"]
156
+ if "host" in args and args["host"]:
157
+ json_data["host"] = args["host"]
158
+
159
+ return {"json_data": json_data}
160
+
161
+
162
+ def _check_connectivity_request(args: dict[str, Any]) -> dict[str, Any]:
163
+ """Transform check_connectivity MCP args to FastAPI params.
164
+
165
+ Args:
166
+ args: MCP tool arguments
167
+
168
+ Returns:
169
+ Dict with 'json_data' key for request body
170
+ """
171
+ return {"json_data": {"path": args["path"]}}
172
+
173
+
174
+ def _check_lvs_request(args: dict[str, Any]) -> dict[str, Any]:
175
+ """Transform check_lvs MCP args to FastAPI params.
176
+
177
+ Args:
178
+ args: MCP tool arguments
179
+
180
+ Returns:
181
+ Dict with 'json_data' key for request body
182
+ """
183
+ return {
184
+ "json_data": {
185
+ "cell": args["cell"],
186
+ "netpath": args["netpath"],
187
+ "cellargs": args.get("cellargs", ""),
188
+ }
189
+ }
190
+
191
+
192
+ # Tool name -> Endpoint mapping
193
+ TOOL_MAPPINGS: dict[str, EndpointMapping] = {
194
+ # Phase 1: Core Building Tools
195
+ "build_cell": EndpointMapping(
196
+ method="GET",
197
+ path="/api/build-cell",
198
+ request_transformer=_build_cell_request,
199
+ ),
200
+ "build_cells": EndpointMapping(
201
+ method="POST",
202
+ path="/api/build-cells",
203
+ request_transformer=_build_cells_request,
204
+ ),
205
+ "list_cells": EndpointMapping(
206
+ method="GET",
207
+ path="/api/cells",
208
+ response_transformer=_list_cells_response,
209
+ ),
210
+ "get_cell_info": EndpointMapping(
211
+ method="GET",
212
+ path="/api/cell-info",
213
+ request_transformer=_get_cell_info_request,
214
+ ),
215
+ "download_gds": EndpointMapping(
216
+ method="GET",
217
+ path="/api/download/{path}.gds",
218
+ request_transformer=_download_gds_request,
219
+ response_transformer=_download_gds_response,
220
+ ),
221
+ # Phase 2: Verification Tools
222
+ "check_drc": EndpointMapping(
223
+ method="POST",
224
+ path="/api/check-drc",
225
+ request_transformer=_check_drc_request,
226
+ ),
227
+ "check_connectivity": EndpointMapping(
228
+ method="POST",
229
+ path="/api/check-connectivity",
230
+ request_transformer=_check_connectivity_request,
231
+ ),
232
+ "check_lvs": EndpointMapping(
233
+ method="POST",
234
+ path="/api/check-lvs",
235
+ request_transformer=_check_lvs_request,
236
+ ),
237
+ }
238
+
239
+
240
+ def get_mapping(tool_name: str) -> EndpointMapping | None:
241
+ """Get the endpoint mapping for a tool.
242
+
243
+ Args:
244
+ tool_name: Name of the MCP tool
245
+
246
+ Returns:
247
+ EndpointMapping or None if not found
248
+ """
249
+ return TOOL_MAPPINGS.get(tool_name)
250
+
251
+
252
+ def transform_request(
253
+ tool_name: str,
254
+ args: dict[str, Any],
255
+ ) -> dict[str, Any]:
256
+ """Transform MCP tool arguments to HTTP request parameters.
257
+
258
+ Args:
259
+ tool_name: Name of the MCP tool
260
+ args: Tool arguments from MCP
261
+
262
+ Returns:
263
+ Dict containing HTTP request parameters (params, json_data, path, etc.)
264
+ """
265
+ mapping = get_mapping(tool_name)
266
+ if mapping is None:
267
+ return {}
268
+
269
+ return mapping.request_transformer(args)
270
+
271
+
272
+ def transform_response(tool_name: str, response: Any) -> Any:
273
+ """Transform HTTP response to MCP format.
274
+
275
+ Args:
276
+ tool_name: Name of the MCP tool
277
+ response: HTTP response from FastAPI
278
+
279
+ Returns:
280
+ Transformed response for MCP
281
+ """
282
+ mapping = get_mapping(tool_name)
283
+ if mapping is None:
284
+ return response
285
+
286
+ return mapping.response_transformer(response)
@@ -0,0 +1,210 @@
1
+ """Read-only server registry for MCP that works with gdsfactoryplus registry.
2
+
3
+ This module provides read-only access to the gdsfactoryplus server registry,
4
+ allowing the MCP to discover and connect to running GDSFactory+ server instances.
5
+ The registry file is managed by gdsfactoryplus and located at:
6
+ ~/.gdsfactory/server-registry.json
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ try:
16
+ import psutil
17
+
18
+ HAS_PSUTIL = True
19
+ except ImportError:
20
+ HAS_PSUTIL = False
21
+
22
+ __all__ = ["ServerRegistry", "ServerInfo", "get_registry_path"]
23
+
24
+
25
+ def get_registry_path() -> Path:
26
+ """Get the path to the server registry file.
27
+
28
+ Returns:
29
+ Path to ~/.gdsfactory/server-registry.json
30
+ """
31
+ gdsfactory_dir = Path.home() / ".gdsfactory"
32
+ gdsfactory_dir.mkdir(parents=True, exist_ok=True)
33
+ return gdsfactory_dir / "server-registry.json"
34
+
35
+
36
+ class ServerInfo:
37
+ """Information about a running GDSFactory+ server.
38
+
39
+ This class is compatible with gdsfactoryplus.registry.ServerInfo
40
+ but doesn't require the full gdsfactoryplus package.
41
+ """
42
+
43
+ def __init__(
44
+ self,
45
+ port: int,
46
+ pid: int,
47
+ project_path: str,
48
+ project_name: str,
49
+ pdk: str | None = None,
50
+ started_at: str | None = None,
51
+ last_heartbeat: str | None = None,
52
+ ) -> None:
53
+ """Initialize server info.
54
+
55
+ Args:
56
+ port: HTTP server port
57
+ pid: Process ID
58
+ project_path: Absolute path to project directory
59
+ project_name: Human-readable project name
60
+ pdk: PDK name (optional)
61
+ started_at: ISO timestamp when server started
62
+ last_heartbeat: ISO timestamp of last heartbeat
63
+ """
64
+ self.port = port
65
+ self.pid = pid
66
+ self.project_path = project_path
67
+ self.project_name = project_name
68
+ self.pdk = pdk
69
+ self.started_at = started_at
70
+ self.last_heartbeat = last_heartbeat
71
+
72
+ def to_dict(self) -> dict[str, Any]:
73
+ """Convert to dictionary."""
74
+ return {
75
+ "port": self.port,
76
+ "pid": self.pid,
77
+ "project_path": self.project_path,
78
+ "project_name": self.project_name,
79
+ "pdk": self.pdk,
80
+ "started_at": self.started_at,
81
+ "last_heartbeat": self.last_heartbeat,
82
+ }
83
+
84
+ @classmethod
85
+ def from_dict(cls, data: dict[str, Any]) -> ServerInfo:
86
+ """Create from dictionary."""
87
+ return cls(
88
+ port=data["port"],
89
+ pid=data["pid"],
90
+ project_path=data["project_path"],
91
+ project_name=data["project_name"],
92
+ pdk=data.get("pdk"),
93
+ started_at=data.get("started_at"),
94
+ last_heartbeat=data.get("last_heartbeat"),
95
+ )
96
+
97
+ def is_alive(self) -> bool:
98
+ """Check if the server process is still running.
99
+
100
+ Returns:
101
+ True if process exists, False otherwise
102
+ If psutil is not available, always returns True
103
+ """
104
+ if not HAS_PSUTIL:
105
+ # Without psutil, assume the process is alive
106
+ # The registry cleanup is handled by gdsfactoryplus
107
+ return True
108
+
109
+ try:
110
+ process = psutil.Process(self.pid)
111
+ return process.is_running()
112
+ except (psutil.NoSuchProcess, psutil.AccessDenied):
113
+ return False
114
+
115
+
116
+ class ServerRegistry:
117
+ """Read-only registry for discovering active GDSFactory+ servers.
118
+
119
+ This registry reads from the same file as gdsfactoryplus.registry.ServerRegistry
120
+ (~/.gdsfactory/server-registry.json) but provides only read access.
121
+
122
+ The registry file is managed by the GDSFactory+ servers themselves.
123
+ This MCP implementation only reads from it to discover running servers.
124
+ """
125
+
126
+ def __init__(self, registry_path: Path | None = None) -> None:
127
+ """Initialize registry with read-only access.
128
+
129
+ Args:
130
+ registry_path: Optional custom path to registry file
131
+ """
132
+ self.registry_path = registry_path or get_registry_path()
133
+
134
+ def _read_registry(self) -> dict[str, Any]:
135
+ """Read the registry file.
136
+
137
+ Returns:
138
+ Registry data as dictionary
139
+ """
140
+ if not self.registry_path.exists():
141
+ return {"servers": {}}
142
+
143
+ try:
144
+ with self.registry_path.open() as f:
145
+ return json.load(f)
146
+ except (FileNotFoundError, json.JSONDecodeError):
147
+ return {"servers": {}}
148
+
149
+ def get_server(self, port: int) -> ServerInfo | None:
150
+ """Get server info by port.
151
+
152
+ Args:
153
+ port: Port number
154
+
155
+ Returns:
156
+ ServerInfo if found and alive, None otherwise
157
+ """
158
+ data = self._read_registry()
159
+ port_key = str(port)
160
+
161
+ if port_key not in data["servers"]:
162
+ return None
163
+
164
+ server_info = ServerInfo.from_dict(data["servers"][port_key])
165
+
166
+ # Check if process is still alive (if psutil is available)
167
+ if HAS_PSUTIL and not server_info.is_alive():
168
+ return None
169
+
170
+ return server_info
171
+
172
+ def get_server_by_project(self, project: str) -> ServerInfo | None:
173
+ """Get server info by project name or path.
174
+
175
+ Args:
176
+ project: Project name or path
177
+
178
+ Returns:
179
+ Server info if found and alive, None otherwise
180
+ """
181
+ for server in self.list_servers():
182
+ if project in {
183
+ server.project_name,
184
+ server.project_path,
185
+ Path(server.project_path).name,
186
+ }:
187
+ return server
188
+ return None
189
+
190
+ def list_servers(self, *, include_dead: bool = False) -> list[ServerInfo]:
191
+ """List all registered servers.
192
+
193
+ Args:
194
+ include_dead: Include servers with dead processes (default: False)
195
+ Only effective if psutil is available
196
+
197
+ Returns:
198
+ List of ServerInfo objects
199
+ """
200
+ data = self._read_registry()
201
+ servers = []
202
+
203
+ for server_data in data["servers"].values():
204
+ server_info = ServerInfo.from_dict(server_data)
205
+
206
+ # Include all servers if psutil is not available or include_dead is True
207
+ if not HAS_PSUTIL or include_dead or server_info.is_alive():
208
+ servers.append(server_info)
209
+
210
+ return servers