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.
gfp_mcp/samples.py ADDED
@@ -0,0 +1,206 @@
1
+ """Core module for downloading and extracting GDSFactory+ sample projects.
2
+
3
+ Provides async functions to download sample project ZIPs from the registry
4
+ and extract sample files (Python, YAML) for use by AI assistants.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import io
11
+ import logging
12
+ import mimetypes
13
+ import zipfile
14
+ from dataclasses import dataclass, field
15
+
16
+ import httpx
17
+
18
+ from .config import MCPConfig
19
+
20
+ __all__ = [
21
+ "GENERAL_PDK_PROJECTS",
22
+ "SAMPLE_EXTENSIONS",
23
+ "SampleFile",
24
+ "SampleProject",
25
+ "get_sample_file_content",
26
+ "list_sample_projects",
27
+ ]
28
+
29
+ logger = logging.getLogger(__name__)
30
+
31
+ GENERAL_PDK_PROJECTS = [
32
+ "photonics--basic--public--pdk",
33
+ "photonics--full--public--pdk",
34
+ ]
35
+
36
+ SAMPLE_EXTENSIONS = {".py", ".yml", ".yaml"}
37
+
38
+
39
+ @dataclass
40
+ class SampleProject:
41
+ """A sample project with its list of sample file paths."""
42
+
43
+ name: str
44
+ files: list[str] = field(default_factory=list)
45
+
46
+
47
+ @dataclass
48
+ class SampleFile:
49
+ """A single sample file with its content."""
50
+
51
+ project: str
52
+ path: str
53
+ content: str
54
+ mime_type: str
55
+
56
+
57
+ async def _download_project_zip(api_key: str, project_name: str) -> bytes:
58
+ """Download a project ZIP from the registry.
59
+
60
+ Args:
61
+ api_key: GFP API key for authentication.
62
+ project_name: Name of the project to download.
63
+
64
+ Returns:
65
+ Raw ZIP file bytes.
66
+
67
+ Raises:
68
+ httpx.HTTPStatusError: If the request fails.
69
+ """
70
+ url = f"{MCPConfig.REGISTRY_API_URL}/project/download/{project_name}"
71
+ headers = {"X-API-Key": api_key}
72
+
73
+ async with httpx.AsyncClient() as client:
74
+ response = await client.get(url, headers=headers, timeout=300.0)
75
+ response.raise_for_status()
76
+ return response.content
77
+
78
+
79
+ def _extract_sample_files(zip_data: bytes, project_name: str) -> list[str]:
80
+ """Extract sample file paths from a project ZIP.
81
+
82
+ Finds files under any ``samples/`` directory, filters by extension,
83
+ and excludes ``__init__.py`` and ``__pycache__`` entries.
84
+
85
+ Args:
86
+ zip_data: Raw ZIP file bytes.
87
+ project_name: Project name (unused, kept for future use).
88
+
89
+ Returns:
90
+ Sorted list of file paths within the ZIP that are sample files.
91
+ """
92
+ sample_files = []
93
+
94
+ with zipfile.ZipFile(io.BytesIO(zip_data)) as zf:
95
+ for name in zf.namelist():
96
+ if zf.getinfo(name).is_dir():
97
+ continue
98
+
99
+ # Must be under a samples/ directory
100
+ parts = name.split("/")
101
+ if "samples" not in parts:
102
+ continue
103
+
104
+ # Filter by extension
105
+ ext = "." + name.rsplit(".", 1)[-1] if "." in name else ""
106
+ if ext not in SAMPLE_EXTENSIONS:
107
+ continue
108
+
109
+ # Exclude __init__.py and __pycache__
110
+ basename = parts[-1]
111
+ if basename == "__init__.py" or "__pycache__" in parts:
112
+ continue
113
+
114
+ sample_files.append(name)
115
+
116
+ return sorted(sample_files)
117
+
118
+
119
+ def _extract_file_content(zip_data: bytes, file_path: str) -> str:
120
+ """Extract a single file's content from a ZIP.
121
+
122
+ Args:
123
+ zip_data: Raw ZIP file bytes.
124
+ file_path: Path within the ZIP to extract.
125
+
126
+ Returns:
127
+ UTF-8 decoded file content.
128
+
129
+ Raises:
130
+ KeyError: If the file is not found in the ZIP.
131
+ """
132
+ with zipfile.ZipFile(io.BytesIO(zip_data)) as zf:
133
+ return zf.read(file_path).decode("utf-8")
134
+
135
+
136
+ def _get_mime_type(path: str) -> str:
137
+ """Determine MIME type from file path.
138
+
139
+ Args:
140
+ path: File path to check.
141
+
142
+ Returns:
143
+ MIME type string, defaults to ``text/plain``.
144
+ """
145
+ mime_type, _ = mimetypes.guess_type(path)
146
+ return mime_type or "text/plain"
147
+
148
+
149
+ async def list_sample_projects(api_key: str) -> list[SampleProject]:
150
+ """Download both General PDK ZIPs and list their sample files.
151
+
152
+ Args:
153
+ api_key: GFP API key for authentication.
154
+
155
+ Returns:
156
+ List of SampleProject instances with file listings.
157
+ """
158
+ tasks = [
159
+ _download_project_zip(api_key, project) for project in GENERAL_PDK_PROJECTS
160
+ ]
161
+ results = await asyncio.gather(*tasks, return_exceptions=True)
162
+
163
+ projects = []
164
+ for project_name, result in zip(GENERAL_PDK_PROJECTS, results):
165
+ if isinstance(result, Exception):
166
+ logger.warning("Failed to download project %s: %s", project_name, result)
167
+ projects.append(SampleProject(name=project_name, files=[]))
168
+ continue
169
+
170
+ files = _extract_sample_files(result, project_name)
171
+ projects.append(SampleProject(name=project_name, files=files))
172
+
173
+ return projects
174
+
175
+
176
+ async def get_sample_file_content(api_key: str, project: str, path: str) -> SampleFile:
177
+ """Download a project ZIP and extract a specific file's content.
178
+
179
+ Args:
180
+ api_key: GFP API key for authentication.
181
+ project: Project name (must be in GENERAL_PDK_PROJECTS).
182
+ path: File path within the ZIP.
183
+
184
+ Returns:
185
+ SampleFile with the file's content and metadata.
186
+
187
+ Raises:
188
+ ValueError: If the project name is not recognized.
189
+ KeyError: If the file is not found in the ZIP.
190
+ """
191
+ if project not in GENERAL_PDK_PROJECTS:
192
+ raise ValueError(
193
+ f"Unknown sample project: {project}. "
194
+ f"Available projects: {GENERAL_PDK_PROJECTS}"
195
+ )
196
+
197
+ zip_data = await _download_project_zip(api_key, project)
198
+ content = _extract_file_content(zip_data, path)
199
+ mime_type = _get_mime_type(path)
200
+
201
+ return SampleFile(
202
+ project=project,
203
+ path=path,
204
+ content=content,
205
+ mime_type=mime_type,
206
+ )
gfp_mcp/server.py ADDED
@@ -0,0 +1,235 @@
1
+ """MCP server implementation for GDSFactory+.
2
+
3
+ This module provides the main MCP server that exposes GDSFactory+ operations
4
+ as tools for AI assistants using the STDIO transport.
5
+
6
+ The server uses a handler-based architecture where each tool has its own
7
+ handler class that encapsulates:
8
+ - Tool definition (name, description, input schema)
9
+ - Request transformation (MCP args -> HTTP params/body)
10
+ - Response transformation (HTTP response -> MCP format)
11
+ - Execution logic (handle method)
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import asyncio
17
+ import logging
18
+ from typing import Any
19
+
20
+ from mcp.server import Server
21
+ from mcp.server.stdio import stdio_server
22
+ from mcp.types import (
23
+ ImageContent,
24
+ Resource,
25
+ TextContent,
26
+ Tool,
27
+ )
28
+
29
+ from .client import FastAPIClient
30
+ from .config import MCPConfig
31
+ from .resources import get_all_resources, get_resource_content
32
+ from .tools import get_all_tools, get_handler
33
+
34
+ __all__ = ["create_server", "run_server", "main"]
35
+
36
+ logger = logging.getLogger(__name__)
37
+
38
+
39
+ _INSTRUCTIONS = """
40
+ GDSFactory+ MCP Server Instructions
41
+ ===================================
42
+
43
+ This MCP server connects AI assistants to GDSFactory+ for photonic IC design.
44
+ Use this context to understand how to interact with the server effectively.
45
+
46
+ Server Discovery
47
+ ----------------
48
+ Use the `list_projects` tool to discover running GDSFactory+ servers / projects.
49
+ Each project has its own server with a unique port.
50
+
51
+ If no servers are found, instruct the user to:
52
+ 1. Open VSCode with the GDSFactory+ extension installed
53
+ 2. Open a GDSFactory+ project folder
54
+ 3. The extension will automatically start and register the server
55
+
56
+ Typically, users will invoke you from the project directory they are working on.
57
+ If unclear, ask the user what project they are working on.
58
+
59
+ Multi-Project Routing
60
+ ---------------------
61
+ All tools (except list_projects) support an optional `project` parameter.
62
+ Use this to target a specific project when multiple GDSFactory+ servers are running.
63
+
64
+ If not specified, the first available server from the registry is used as a fallback.
65
+ You are adviced against ever using this fallback.
66
+
67
+ Designing Custom cells / factories
68
+ ---------------------------------
69
+ Users may request you to build and design custom cells. Always consult the `list_samples` tool to discover examples of how to design cells.
70
+ Always make sure to run DRC/LVS/Connectivity checks on custom cells you have built.
71
+ Before running a DRC consult with the user if they would like to run the DRC, as these can take a long time to complete.
72
+ When users ask for specific characteristics of a cell / factory, you are to run simulations and use python scripts to analyze and verify the results.
73
+ Always write your new designs in python code, even though an example might show yaml.
74
+ Make sure to save your designs in project_name/custom_cells/**.py, and notify the user that they are saved here.
75
+
76
+ Best Practices
77
+ --------------
78
+ 1. Always call `list_projects` first to discover available servers
79
+ 2. Use the `project` parameter when working with multiple projects
80
+ 3. For verification tools (DRC, connectivity, LVS), provide full paths to GDS files
81
+ 4. Simulation results may be large - the server optimizes responses for token efficiency
82
+ 5. When building cells, use the `visualize` parameter to get PNG previews
83
+ """
84
+
85
+
86
+ def create_server(api_url: str | None = None) -> Server:
87
+ """Create an MCP server instance.
88
+
89
+ Args:
90
+ api_url: Optional FastAPI base URL (default from config)
91
+
92
+ Returns:
93
+ Configured MCP Server instance
94
+ """
95
+ server = Server("gdsfactoryplus", instructions=_INSTRUCTIONS.strip())
96
+
97
+ client = FastAPIClient(api_url)
98
+
99
+ @server.list_tools()
100
+ async def list_tools() -> list[Tool]:
101
+ """List all available MCP tools.
102
+
103
+ Returns:
104
+ List of tool definitions
105
+ """
106
+ tools = get_all_tools()
107
+ logger.info("Listing %d tools", len(tools))
108
+ return tools
109
+
110
+ @server.list_resources()
111
+ async def list_resources() -> list[Resource]:
112
+ """List all available MCP resources.
113
+
114
+ Returns:
115
+ List of resource definitions
116
+ """
117
+ resources = get_all_resources()
118
+ logger.info("Listing %d resources", len(resources))
119
+ return resources
120
+
121
+ @server.read_resource()
122
+ async def read_resource(uri: str) -> str:
123
+ """Read a specific resource by URI.
124
+
125
+ Args:
126
+ uri: Resource URI
127
+
128
+ Returns:
129
+ Resource content as string
130
+
131
+ Raises:
132
+ ValueError: If resource URI is not found
133
+ """
134
+ logger.info("Resource requested: %s", uri)
135
+
136
+ content = get_resource_content(uri)
137
+ if content is None:
138
+ error_msg = f"Unknown resource URI: {uri}"
139
+ logger.error(error_msg)
140
+ raise ValueError(error_msg)
141
+
142
+ return content
143
+
144
+ @server.call_tool()
145
+ async def call_tool(
146
+ name: str, arguments: dict[str, Any]
147
+ ) -> list[TextContent | ImageContent]:
148
+ """Call an MCP tool.
149
+
150
+ Uses handler dispatch to route tool calls to the appropriate handler.
151
+ Each handler encapsulates request/response transformation and execution.
152
+
153
+ Args:
154
+ name: Tool name
155
+ arguments: Tool arguments
156
+
157
+ Returns:
158
+ List of text and/or image content responses
159
+ """
160
+ logger.info("Tool called: %s", name)
161
+ logger.debug("Arguments: %s", arguments)
162
+
163
+ handler = get_handler(name)
164
+ if handler is None:
165
+ import json
166
+
167
+ error_msg = f"Unknown tool: {name}"
168
+ logger.error(error_msg)
169
+ return [TextContent(type="text", text=json.dumps({"error": error_msg}))]
170
+
171
+ return await handler.handle(arguments, client)
172
+
173
+ server._http_client = client # type: ignore[attr-defined] # noqa: SLF001
174
+
175
+ return server
176
+
177
+
178
+ async def run_server(api_url: str | None = None) -> None:
179
+ """Run the MCP server with STDIO transport.
180
+
181
+ Args:
182
+ api_url: Optional FastAPI base URL (default from config)
183
+ """
184
+ log_level = logging.DEBUG if MCPConfig.DEBUG else logging.INFO
185
+ logging.basicConfig(
186
+ level=log_level,
187
+ format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
188
+ )
189
+
190
+ logger.info("Starting GDSFactory+ MCP server")
191
+ base_url_info = MCPConfig.get_api_url(api_url) or "registry-based routing"
192
+ logger.info("Base URL: %s", base_url_info)
193
+ logger.info("Timeout: %ds", MCPConfig.get_timeout())
194
+
195
+ server = create_server(api_url)
196
+ client: FastAPIClient = server._http_client # type: ignore[attr-defined] # noqa: SLF001
197
+
198
+ try:
199
+ await client.start()
200
+
201
+ async with stdio_server() as (read_stream, write_stream):
202
+ logger.info("MCP server ready (STDIO transport)")
203
+ await server.run(
204
+ read_stream,
205
+ write_stream,
206
+ server.create_initialization_options(),
207
+ )
208
+
209
+ except KeyboardInterrupt:
210
+ logger.info("Shutting down MCP server (keyboard interrupt)")
211
+ except Exception:
212
+ logger.exception("MCP server error")
213
+ raise
214
+ finally:
215
+ await client.close()
216
+ logger.info("MCP server stopped")
217
+
218
+
219
+ def main(api_url: str | None = None) -> None:
220
+ """Main entry point for MCP server.
221
+
222
+ Args:
223
+ api_url: Optional FastAPI base URL (default from config)
224
+ """
225
+ try:
226
+ asyncio.run(run_server(api_url))
227
+ except KeyboardInterrupt:
228
+ pass
229
+ except Exception:
230
+ logger.exception("Fatal error")
231
+ raise
232
+
233
+
234
+ if __name__ == "__main__":
235
+ main()
@@ -0,0 +1,134 @@
1
+ """MCP tool definitions and handlers for GDSFactory+.
2
+
3
+ This package contains all tool handlers, each co-locating:
4
+ - MCP Tool definition (name, description, input schema)
5
+ - Request transformation (MCP args -> HTTP params/body)
6
+ - Response transformation (HTTP response -> MCP format)
7
+ - Execution logic (handle method)
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import TYPE_CHECKING
13
+
14
+ from .base import (
15
+ PROJECT_PARAM_SCHEMA,
16
+ EndpointMapping,
17
+ ToolHandler,
18
+ add_project_param,
19
+ )
20
+
21
+ # Import all handlers
22
+ from .bbox import GenerateBboxHandler
23
+ from .build import BuildCellsHandler
24
+ from .cells import GetCellInfoHandler, ListCellsHandler
25
+ from .connectivity import CheckConnectivityHandler
26
+ from .drc import CheckDrcHandler
27
+ from .freeze import FreezeCellHandler
28
+ from .lvs import CheckLvsHandler
29
+ from .pdk import GetPdkInfoHandler
30
+ from .port import GetPortCenterHandler
31
+ from .project import GetProjectInfoHandler, ListProjectsHandler
32
+ from .samples import GetSampleFileHandler, ListSamplesHandler
33
+ from .simulation import SimulateComponentHandler
34
+
35
+ if TYPE_CHECKING:
36
+ from mcp.types import Tool
37
+
38
+ __all__ = [
39
+ # Base classes
40
+ "EndpointMapping",
41
+ "ToolHandler",
42
+ # Registry and utilities
43
+ "TOOL_HANDLERS",
44
+ "get_all_tools",
45
+ "get_tool_by_name",
46
+ "get_handler",
47
+ "PROJECT_PARAM_SCHEMA",
48
+ "add_project_param",
49
+ # Handler classes
50
+ "ListProjectsHandler",
51
+ "GetProjectInfoHandler",
52
+ "BuildCellsHandler",
53
+ "ListCellsHandler",
54
+ "GetCellInfoHandler",
55
+ "CheckDrcHandler",
56
+ "CheckConnectivityHandler",
57
+ "CheckLvsHandler",
58
+ "SimulateComponentHandler",
59
+ "GetPortCenterHandler",
60
+ "GenerateBboxHandler",
61
+ "FreezeCellHandler",
62
+ "GetPdkInfoHandler",
63
+ "ListSamplesHandler",
64
+ "GetSampleFileHandler",
65
+ ]
66
+
67
+
68
+ def _create_handler_registry() -> dict[str, ToolHandler]:
69
+ """Create the tool handlers registry.
70
+
71
+ Returns:
72
+ Dict mapping tool names to handler instances
73
+ """
74
+ handlers = [
75
+ # Project discovery tools
76
+ ListProjectsHandler(),
77
+ GetProjectInfoHandler(),
78
+ # Core building tools
79
+ BuildCellsHandler(),
80
+ ListCellsHandler(),
81
+ GetCellInfoHandler(),
82
+ # Verification tools
83
+ CheckDrcHandler(),
84
+ CheckConnectivityHandler(),
85
+ CheckLvsHandler(),
86
+ # Advanced tools
87
+ SimulateComponentHandler(),
88
+ GetPortCenterHandler(),
89
+ GenerateBboxHandler(),
90
+ FreezeCellHandler(),
91
+ GetPdkInfoHandler(),
92
+ # Sample project tools
93
+ ListSamplesHandler(),
94
+ GetSampleFileHandler(),
95
+ ]
96
+ return {handler.name: handler for handler in handlers}
97
+
98
+
99
+ # Tool handlers registry - each handler is keyed by its tool name for O(1) lookup
100
+ TOOL_HANDLERS: dict[str, ToolHandler] = _create_handler_registry()
101
+
102
+
103
+ def get_all_tools() -> list[Tool]:
104
+ """Get all available MCP tools.
105
+
106
+ Returns:
107
+ List of Tool definitions from all registered handlers
108
+ """
109
+ return [handler.definition for handler in TOOL_HANDLERS.values()]
110
+
111
+
112
+ def get_tool_by_name(name: str) -> Tool | None:
113
+ """Get a tool definition by name.
114
+
115
+ Args:
116
+ name: Tool name to look up
117
+
118
+ Returns:
119
+ Tool definition if found, None otherwise
120
+ """
121
+ handler = TOOL_HANDLERS.get(name)
122
+ return handler.definition if handler else None
123
+
124
+
125
+ def get_handler(name: str) -> ToolHandler | None:
126
+ """Get a tool handler by name.
127
+
128
+ Args:
129
+ name: Tool name to look up
130
+
131
+ Returns:
132
+ ToolHandler instance if found, None otherwise
133
+ """
134
+ return TOOL_HANDLERS.get(name)