gfp-mcp 0.2.1__py3-none-any.whl → 0.3.2__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.
@@ -1,22 +1,22 @@
1
- """MCP Standalone Server for GDSFactory+.
1
+ """GDSFactory+ MCP Server.
2
2
 
3
3
  This package provides a Model Context Protocol (MCP) server that exposes
4
4
  GDSFactory+ operations as tools for AI assistants. The server uses STDIO
5
5
  transport and proxies requests to the FastAPI backend.
6
6
 
7
7
  Architecture:
8
- - Standalone MCP server (this package)
8
+ - Tool handlers in gfp_mcp/tools/ with co-located definitions and transformers
9
9
  - STDIO transport for universal compatibility
10
- - HTTP proxy to FastAPI backend
10
+ - HTTP proxy to FastAPI backend via client.py
11
+ - Multi-project routing via server registry
11
12
  - Zero changes to existing FastAPI server
12
- - No database conflicts (only FastAPI touches SQLite)
13
13
 
14
14
  Usage:
15
- from gdsfactoryplus.mcp_standalone import main
15
+ from gfp_mcp import main
16
16
  main()
17
17
 
18
18
  Or via CLI:
19
- gfp mcp-serve
19
+ gfp-mcp-serve
20
20
  """
21
21
 
22
22
  from __future__ import annotations
@@ -25,18 +25,20 @@ from .client import FastAPIClient
25
25
  from .config import MCPConfig
26
26
  from .resources import get_all_resources, get_resource_content
27
27
  from .server import create_server, main, run_server
28
- from .tools import get_all_tools, get_tool_by_name
28
+ from .tools import get_all_tools, get_handler, get_tool_by_name
29
29
 
30
30
  __all__ = [
31
+ "__version__",
31
32
  "FastAPIClient",
32
33
  "MCPConfig",
33
34
  "create_server",
34
35
  "main",
35
36
  "run_server",
36
37
  "get_all_tools",
38
+ "get_handler",
37
39
  "get_tool_by_name",
38
40
  "get_all_resources",
39
41
  "get_resource_content",
40
42
  ]
41
43
 
42
- __version__ = "0.2.1"
44
+ __version__ = "0.3.2"
@@ -43,6 +43,21 @@ class FastAPIClient:
43
43
  self._client: httpx.AsyncClient | None = None
44
44
  self._registry = ServerRegistry()
45
45
 
46
+ def _has_available_servers(self) -> bool:
47
+ """Check if any servers are available in the registry."""
48
+ return len(self._registry.list_servers()) > 0
49
+
50
+ def _get_default_server_url(self) -> str | None:
51
+ """Get the first available server URL from registry if no base_url configured."""
52
+ if self.base_url:
53
+ return self.base_url
54
+
55
+ servers = self._registry.list_servers()
56
+ if servers:
57
+ return f"http://localhost:{servers[0].port}"
58
+
59
+ return None
60
+
46
61
  async def __aenter__(self) -> Self:
47
62
  """Enter async context."""
48
63
  await self.start()
@@ -55,12 +70,17 @@ class FastAPIClient:
55
70
  async def start(self) -> None:
56
71
  """Start the HTTP client with connection pooling."""
57
72
  if self._client is None:
73
+ base_url = self.base_url or "http://localhost"
74
+
58
75
  self._client = httpx.AsyncClient(
59
- base_url=self.base_url,
76
+ base_url=base_url,
60
77
  timeout=httpx.Timeout(self.timeout),
61
78
  limits=httpx.Limits(max_keepalive_connections=5, max_connections=10),
62
79
  )
63
- logger.debug("HTTP client started with base URL: %s", self.base_url)
80
+ logger.debug(
81
+ "HTTP client started with base URL: %s (resolved per-request if needed)",
82
+ self.base_url or "from registry",
83
+ )
64
84
 
65
85
  async def close(self) -> None:
66
86
  """Close the HTTP client."""
@@ -79,22 +99,46 @@ class FastAPIClient:
79
99
  Base URL for the request
80
100
 
81
101
  Raises:
82
- ValueError: If project not found in registry
102
+ ValueError: If base URL cannot be resolved
83
103
  """
84
- # If no project specified, use default base_url
85
- if project is None:
104
+ if project is not None:
105
+ server_info = self._registry.get_server_by_project(project)
106
+ if server_info is None:
107
+ available = self._registry.list_servers()
108
+ if available:
109
+ project_list = ", ".join([s.project_name for s in available[:3]])
110
+ if len(available) > 3:
111
+ project_list += f", ... and {len(available) - 3} more"
112
+ msg = (
113
+ f"Project '{project}' not found in registry. "
114
+ f"Available projects: {project_list}. "
115
+ "Use list_projects tool to see all running servers."
116
+ )
117
+ else:
118
+ msg = (
119
+ f"Project '{project}' not found. No GDSFactory+ servers are running. "
120
+ "Please open a GDSFactory+ project in VSCode with the extension installed."
121
+ )
122
+ raise ValueError(msg)
123
+
124
+ return f"http://localhost:{server_info.port}"
125
+
126
+ if self.base_url:
86
127
  return self.base_url
87
128
 
88
- # Look up project in registry
89
- server_info = self._registry.get_server_by_project(project)
90
- if server_info is None:
91
- msg = (
92
- f"Project '{project}' not found in registry. "
93
- "Make sure the server is running for this project."
129
+ default_url = self._get_default_server_url()
130
+ if default_url:
131
+ logger.info(
132
+ "No project specified, using first available server: %s", default_url
94
133
  )
95
- raise ValueError(msg)
134
+ return default_url
96
135
 
97
- return f"http://localhost:{server_info.port}"
136
+ msg = (
137
+ "No project specified and no GDSFactory+ servers are running. "
138
+ "Either: (1) Start a server by opening a GDSFactory+ project in VSCode, "
139
+ "(2) Specify a project parameter, or (3) Set GFP_API_URL environment variable."
140
+ )
141
+ raise ValueError(msg)
98
142
 
99
143
  async def request(
100
144
  self,
@@ -125,7 +169,6 @@ class FastAPIClient:
125
169
  if self._client is None:
126
170
  await self.start()
127
171
 
128
- # Resolve the base URL for this request
129
172
  base_url = self._resolve_base_url(project)
130
173
 
131
174
  last_error = None
@@ -143,7 +186,6 @@ class FastAPIClient:
143
186
  base_url,
144
187
  )
145
188
 
146
- # Build full URL with the resolved base URL
147
189
  full_url = f"{base_url}{path}"
148
190
 
149
191
  response = await self._client.request( # type: ignore[union-attr]
@@ -155,7 +197,6 @@ class FastAPIClient:
155
197
  )
156
198
  response.raise_for_status()
157
199
 
158
- # Try to parse JSON, fall back to text
159
200
  try:
160
201
  return response.json()
161
202
  except (ValueError, TypeError):
@@ -165,19 +206,16 @@ class FastAPIClient:
165
206
  last_error = e
166
207
  logger.warning("Request failed (attempt %d): %s", attempt + 1, e)
167
208
 
168
- # Don't retry on client errors (4xx)
169
209
  if (
170
210
  isinstance(e, httpx.HTTPStatusError)
171
211
  and 400 <= e.response.status_code < 500
172
212
  ):
173
213
  raise
174
214
 
175
- # Exponential backoff for retries
176
215
  if attempt < MCPConfig.MAX_RETRIES - 1:
177
216
  await asyncio.sleep(backoff)
178
217
  backoff *= 2
179
218
 
180
- # All retries failed
181
219
  logger.error("All %d attempts failed", MCPConfig.MAX_RETRIES)
182
220
  raise last_error # type: ignore[misc]
183
221
 
@@ -256,17 +294,3 @@ class FastAPIClient:
256
294
  }
257
295
  for server in servers
258
296
  ]
259
-
260
- async def get_project_info(self, project: str) -> dict[str, Any]:
261
- """Get detailed information about a specific project.
262
-
263
- Args:
264
- project: Project name or path
265
-
266
- Returns:
267
- Project information from the server's /info endpoint
268
-
269
- Raises:
270
- ValueError: If project not found
271
- """
272
- return await self.request("GET", "/info", project=project)
gfp_mcp/config.py ADDED
@@ -0,0 +1,161 @@
1
+ """Configuration management for MCP standalone server."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from pathlib import Path
7
+ from typing import Final
8
+
9
+ try:
10
+ import tomllib # Python 3.11+
11
+ except ImportError:
12
+ import tomli as tomllib # type: ignore[import-not-found]
13
+
14
+ __all__ = ["MCPConfig", "get_gfp_api_key", "set_gfp_api_key"]
15
+
16
+
17
+ class MCPConfig:
18
+ """Configuration for MCP standalone server.
19
+
20
+ Manages environment variables and default settings for the MCP server
21
+ that proxies requests to the FastAPI backend.
22
+ """
23
+
24
+ API_URL: Final[str | None] = os.getenv("GFP_API_URL")
25
+
26
+ TIMEOUT: Final[int] = int(os.getenv("GFP_MCP_TIMEOUT", "300"))
27
+
28
+ DEBUG: Final[bool] = os.getenv("GFP_MCP_DEBUG", "false").lower() in (
29
+ "true",
30
+ "1",
31
+ "yes",
32
+ )
33
+
34
+ REGISTRY_API_URL: Final[str] = "https://registry.gdsfactory.com"
35
+
36
+ MAX_RETRIES: Final[int] = 3
37
+ RETRY_BACKOFF: Final[float] = 0.5 # Initial backoff in seconds
38
+
39
+ @classmethod
40
+ def get_api_url(cls, override: str | None = None) -> str | None:
41
+ """Get the FastAPI base URL.
42
+
43
+ Args:
44
+ override: Optional URL to override the environment variable
45
+
46
+ Returns:
47
+ The API base URL or None if not configured
48
+ """
49
+ return override or cls.API_URL
50
+
51
+ @classmethod
52
+ def get_timeout(cls) -> int:
53
+ """Get the timeout for tool calls.
54
+
55
+ Returns:
56
+ Timeout in seconds
57
+ """
58
+ return cls.TIMEOUT
59
+
60
+
61
+ def _read_api_key_from_toml(file_path: Path) -> str | None:
62
+ """Read the API key from a TOML configuration file.
63
+
64
+ Args:
65
+ file_path: Path to the TOML file.
66
+
67
+ Returns:
68
+ The API key string, or None if not found or file doesn't exist.
69
+ """
70
+ if not file_path.exists():
71
+ return None
72
+
73
+ try:
74
+ with open(file_path, "rb") as f:
75
+ config = tomllib.load(f)
76
+
77
+ return (
78
+ config.get("tool", {}).get("gdsfactoryplus", {}).get("api", {}).get("key")
79
+ )
80
+ except Exception:
81
+ return None
82
+
83
+
84
+ def get_gfp_api_key() -> str | None:
85
+ """Retrieve the GFP API key from environment variables or config files.
86
+
87
+ Checks sources in priority order:
88
+ 1. GFP_API_KEY environment variable
89
+ 2. ~/.gdsfactory/gdsfactoryplus.toml
90
+ 3. ./pyproject.toml
91
+
92
+ Returns:
93
+ The API key string, or None if not found.
94
+ """
95
+ # 1. Check environment variable (highest priority)
96
+ api_key = os.environ.get("GFP_API_KEY")
97
+ if api_key:
98
+ return api_key
99
+
100
+ # 2. Check global config file: ~/.gdsfactory/gdsfactoryplus.toml
101
+ global_config_path = Path.home() / ".gdsfactory" / "gdsfactoryplus.toml"
102
+ api_key = _read_api_key_from_toml(global_config_path)
103
+ if api_key:
104
+ return api_key
105
+
106
+ # 3. Check local project config: ./pyproject.toml
107
+ local_config_path = Path.cwd() / "pyproject.toml"
108
+ api_key = _read_api_key_from_toml(local_config_path)
109
+ if api_key:
110
+ return api_key
111
+
112
+ return None
113
+
114
+
115
+ def set_gfp_api_key(api_key: str) -> None:
116
+ """Save the GFP API key to the global config file.
117
+
118
+ Writes to: ~/.gdsfactory/gdsfactoryplus.toml
119
+
120
+ Args:
121
+ api_key: The API key to save.
122
+
123
+ Raises:
124
+ ValueError: If api_key is empty or None.
125
+ """
126
+ if not api_key:
127
+ raise ValueError("API key is required")
128
+
129
+ config_dir = Path.home() / ".gdsfactory"
130
+ config_path = config_dir / "gdsfactoryplus.toml"
131
+
132
+ # Create directory if it doesn't exist
133
+ config_dir.mkdir(parents=True, exist_ok=True)
134
+
135
+ # Load existing config or create new one
136
+ config: dict = {}
137
+ if config_path.exists():
138
+ with open(config_path, "rb") as f:
139
+ config = tomllib.load(f)
140
+
141
+ # Set the API key in the nested structure
142
+ if "tool" not in config:
143
+ config["tool"] = {}
144
+ if "gdsfactoryplus" not in config["tool"]:
145
+ config["tool"]["gdsfactoryplus"] = {}
146
+ if "api" not in config["tool"]["gdsfactoryplus"]:
147
+ config["tool"]["gdsfactoryplus"]["api"] = {}
148
+
149
+ config["tool"]["gdsfactoryplus"]["api"]["key"] = api_key
150
+
151
+ # Write back to file using tomli_w if available, otherwise manual formatting
152
+ try:
153
+ import tomli_w
154
+
155
+ with open(config_path, "wb") as f:
156
+ tomli_w.dump(config, f)
157
+ except ImportError:
158
+ # Fallback: write TOML manually for this simple structure
159
+ with open(config_path, "w") as f:
160
+ f.write("[tool.gdsfactoryplus.api]\n")
161
+ f.write(f'key = "{api_key}"\n')
@@ -102,8 +102,6 @@ class ServerInfo:
102
102
  If psutil is not available, always returns True
103
103
  """
104
104
  if not HAS_PSUTIL:
105
- # Without psutil, assume the process is alive
106
- # The registry cleanup is handled by gdsfactoryplus
107
105
  return True
108
106
 
109
107
  try:
@@ -163,7 +161,6 @@ class ServerRegistry:
163
161
 
164
162
  server_info = ServerInfo.from_dict(data["servers"][port_key])
165
163
 
166
- # Check if process is still alive (if psutil is available)
167
164
  if HAS_PSUTIL and not server_info.is_alive():
168
165
  return None
169
166
 
@@ -203,7 +200,6 @@ class ServerRegistry:
203
200
  for server_data in data["servers"].values():
204
201
  server_info = ServerInfo.from_dict(server_data)
205
202
 
206
- # Include all servers if psutil is not available or include_dead is True
207
203
  if not HAS_PSUTIL or include_dead or server_info.is_alive():
208
204
  servers.append(server_info)
209
205
 
gfp_mcp/render.py ADDED
@@ -0,0 +1,139 @@
1
+ """GDS rendering utilities for MCP server.
2
+
3
+ This module provides utilities for rendering GDS files to PNG images,
4
+ enabling automatic image return when building cells.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import base64
10
+ import logging
11
+ from pathlib import Path
12
+
13
+ from mcp.types import ImageContent
14
+
15
+ from .registry import ServerRegistry
16
+ from .utils import wait_for_gds_file
17
+
18
+ __all__ = ["render_gds_to_png", "render_built_cells", "HAS_KLAYOUT"]
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+ try:
23
+ import klayout.db as db
24
+ import klayout.lay as lay
25
+
26
+ HAS_KLAYOUT = True
27
+ except ImportError:
28
+ HAS_KLAYOUT = False
29
+ db = None # type: ignore[assignment]
30
+ lay = None # type: ignore[assignment]
31
+
32
+
33
+ def render_gds_to_png(
34
+ gds_path: Path,
35
+ width: int = 800,
36
+ height: int = 600,
37
+ ) -> bytes | None:
38
+ """Render GDS file to PNG image using klayout.
39
+
40
+ Args:
41
+ gds_path: Path to the GDS file to render
42
+ width: Image width in pixels (default: 800)
43
+ height: Image height in pixels (default: 600)
44
+
45
+ Returns:
46
+ PNG image as bytes, or None if klayout is not available or rendering fails
47
+ """
48
+ if not HAS_KLAYOUT:
49
+ logger.debug("klayout not available, skipping GDS rendering")
50
+ return None
51
+
52
+ if not gds_path.exists():
53
+ logger.warning("GDS file not found for rendering: %s", gds_path)
54
+ return None
55
+
56
+ try:
57
+ layout = db.Layout()
58
+ layout.read(str(gds_path))
59
+
60
+ layout_view = lay.LayoutView()
61
+ layout_view.load_layout(str(gds_path), True)
62
+
63
+ layout_view.max_hier()
64
+
65
+ layout_view.zoom_fit()
66
+
67
+ pixel_buffer = layout_view.get_pixels(width, height)
68
+
69
+ png_bytes = pixel_buffer.to_png_data()
70
+
71
+ logger.debug("Rendered GDS to PNG: %s (%dx%d)", gds_path, width, height)
72
+ return png_bytes
73
+
74
+ except Exception as e:
75
+ logger.warning("Failed to render GDS to PNG: %s - %s", gds_path, e)
76
+ return None
77
+
78
+
79
+ async def render_built_cells(
80
+ cell_names: list[str],
81
+ project: str | None,
82
+ ) -> list[ImageContent]:
83
+ """Render built GDS cells to PNG images.
84
+
85
+ Args:
86
+ cell_names: List of cell names to render
87
+ project: Optional project name for routing
88
+
89
+ Returns:
90
+ List of ImageContent for successfully rendered cells
91
+ """
92
+ images: list[ImageContent] = []
93
+
94
+ if not cell_names:
95
+ return images
96
+
97
+ if not HAS_KLAYOUT:
98
+ logger.debug("klayout not available, skipping cell rendering")
99
+ return images
100
+
101
+ registry = ServerRegistry()
102
+ server_info = None
103
+
104
+ if project:
105
+ server_info = registry.get_server_by_project(project)
106
+ if not server_info:
107
+ servers = registry.list_servers()
108
+ if servers:
109
+ server_info = servers[0]
110
+
111
+ if not server_info:
112
+ logger.debug(
113
+ "No server found in registry, cannot determine project path for GDS rendering"
114
+ )
115
+ return images
116
+
117
+ project_path = Path(server_info.project_path)
118
+ gds_dir = project_path / "build" / "gds"
119
+
120
+ for cell_name in cell_names:
121
+ gds_path = gds_dir / f"{cell_name}.gds"
122
+
123
+ if await wait_for_gds_file(gds_path):
124
+ png_bytes = render_gds_to_png(gds_path)
125
+ if png_bytes:
126
+ images.append(
127
+ ImageContent(
128
+ type="image",
129
+ data=base64.b64encode(png_bytes).decode("utf-8"),
130
+ mimeType="image/png",
131
+ )
132
+ )
133
+ logger.info("Added PNG image for cell: %s", cell_name)
134
+ else:
135
+ logger.debug("Failed to render GDS to PNG for cell: %s", cell_name)
136
+ else:
137
+ logger.debug("GDS file not found within timeout for cell: %s", cell_name)
138
+
139
+ return images
@@ -15,7 +15,6 @@ __all__ = [
15
15
  ]
16
16
 
17
17
 
18
- # Define available resources
19
18
  RESOURCES: list[Resource] = [
20
19
  Resource(
21
20
  uri="instructions://build_custom_cell",
@@ -30,7 +29,6 @@ RESOURCES: list[Resource] = [
30
29
  ]
31
30
 
32
31
 
33
- # Resource content
34
32
  RESOURCE_CONTENT = {
35
33
  "instructions://build_custom_cell": """---
36
34
  Workflow: Creating Custom Components in GDSFactory+ Projects
@@ -71,7 +69,6 @@ def custom_component_name(
71
69
  Returns:
72
70
  Component description
73
71
  \"\"\"
74
- # Implementation using PDK functions
75
72
  c = pdk_function(parameters...)
76
73
  return c
77
74