gfp-mcp 0.2.4__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/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)
gfp_mcp/tools/base.py ADDED
@@ -0,0 +1,235 @@
1
+ """Base classes for tool handlers.
2
+
3
+ This module provides the foundational classes for the MCP tool handler
4
+ architecture, including the abstract ToolHandler base class and
5
+ EndpointMapping for HTTP routing.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import logging
12
+ from abc import ABC, abstractmethod
13
+ from dataclasses import dataclass
14
+ from typing import TYPE_CHECKING, Any
15
+
16
+ from mcp.types import ImageContent, TextContent, Tool
17
+
18
+ if TYPE_CHECKING:
19
+ from ..client import FastAPIClient
20
+
21
+ __all__ = [
22
+ "EndpointMapping",
23
+ "ToolHandler",
24
+ "PROJECT_PARAM_SCHEMA",
25
+ "add_project_param",
26
+ ]
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+ # Common schema components for project routing
31
+ PROJECT_PARAM_SCHEMA = {
32
+ "project": {
33
+ "type": "string",
34
+ "description": (
35
+ "Optional project name or path to route this request to a specific server. "
36
+ "If not provided, uses the first available server from the registry or "
37
+ "GFP_API_URL if set. Use list_projects to see available projects."
38
+ ),
39
+ },
40
+ }
41
+
42
+
43
+ def add_project_param(schema: dict) -> dict:
44
+ """Add optional project parameter to a tool schema.
45
+
46
+ Args:
47
+ schema: Tool input schema dict
48
+
49
+ Returns:
50
+ Schema with project parameter added to properties
51
+ """
52
+ if "properties" not in schema:
53
+ schema["properties"] = {}
54
+ schema["properties"].update(PROJECT_PARAM_SCHEMA)
55
+ return schema
56
+
57
+
58
+ @dataclass
59
+ class EndpointMapping:
60
+ """Configuration for mapping an MCP tool to a FastAPI endpoint.
61
+
62
+ This class defines the HTTP method and path for a tool's backend endpoint.
63
+ Unlike the previous implementation in mappings.py, request and response
64
+ transformers are now methods on the ToolHandler class.
65
+
66
+ Attributes:
67
+ method: HTTP method (GET, POST, etc.)
68
+ path: API endpoint path (e.g., "/api/build-cells")
69
+ """
70
+
71
+ method: str
72
+ path: str
73
+
74
+
75
+ class ToolHandler(ABC):
76
+ """Abstract base class for MCP tool handlers.
77
+
78
+ Each tool handler encapsulates:
79
+ - The MCP Tool definition (name, description, input schema)
80
+ - The HTTP endpoint mapping (method, path)
81
+ - Request transformation (MCP args -> HTTP params/body)
82
+ - Response transformation (HTTP response -> MCP format)
83
+ - Execution logic (handle method)
84
+
85
+ Subclasses must implement the `name` and `definition` properties.
86
+ Other methods have sensible defaults but can be overridden.
87
+ """
88
+
89
+ @property
90
+ @abstractmethod
91
+ def name(self) -> str:
92
+ """Tool name identifier.
93
+
94
+ This must match the name in the Tool definition and is used
95
+ for routing in the server's call_tool handler.
96
+ """
97
+ ...
98
+
99
+ @property
100
+ @abstractmethod
101
+ def definition(self) -> Tool:
102
+ """MCP Tool definition.
103
+
104
+ Returns the complete Tool object with name, description,
105
+ and input schema for the MCP protocol.
106
+ """
107
+ ...
108
+
109
+ @property
110
+ def mapping(self) -> EndpointMapping | None:
111
+ """Endpoint mapping for HTTP-backed tools.
112
+
113
+ Returns None for registry-only tools (like list_projects)
114
+ that don't make HTTP requests to the backend.
115
+ """
116
+ return None
117
+
118
+ def transform_request(self, args: dict[str, Any]) -> dict[str, Any]:
119
+ """Transform MCP tool arguments to HTTP request parameters.
120
+
121
+ Override this method to customize how tool arguments are
122
+ converted to HTTP request format.
123
+
124
+ Args:
125
+ args: MCP tool arguments from the client
126
+
127
+ Returns:
128
+ Dict that may contain:
129
+ - "params": Query parameters for GET requests
130
+ - "json_data": JSON body for POST requests
131
+ - "data": Form data for POST requests
132
+ - "path": Dynamic path override (e.g., "/api/cell/{name}")
133
+ - "fallback_path": Alternative endpoint for 404 fallback
134
+ - "fallback_json_data": Alternative body for fallback request
135
+ """
136
+ return {}
137
+
138
+ def transform_response(self, response: Any) -> Any:
139
+ """Transform HTTP response to MCP-friendly format.
140
+
141
+ Override this method to customize how backend responses are
142
+ formatted for the AI assistant.
143
+
144
+ Args:
145
+ response: Raw response from the FastAPI backend
146
+
147
+ Returns:
148
+ Transformed response (typically a dict or the original response)
149
+ """
150
+ return response
151
+
152
+ async def handle(
153
+ self,
154
+ arguments: dict[str, Any],
155
+ client: FastAPIClient,
156
+ ) -> list[TextContent | ImageContent]:
157
+ """Execute the tool and return results.
158
+
159
+ This is the main entry point called by the server's call_tool handler.
160
+ The default implementation uses the endpoint mapping and transformers.
161
+ Override for custom execution logic (e.g., registry-only tools).
162
+
163
+ Args:
164
+ arguments: MCP tool arguments from the client
165
+ client: FastAPI client for making HTTP requests
166
+
167
+ Returns:
168
+ List of TextContent and/or ImageContent responses
169
+ """
170
+ import httpx
171
+
172
+ mapping = self.mapping
173
+ if mapping is None:
174
+ error_msg = f"Tool {self.name} has no endpoint mapping"
175
+ logger.error(error_msg)
176
+ return [TextContent(type="text", text=json.dumps({"error": error_msg}))]
177
+
178
+ try:
179
+ project = arguments.get("project")
180
+ transformed = self.transform_request(arguments)
181
+
182
+ method = mapping.method
183
+ path = transformed.get("path", mapping.path)
184
+ params = transformed.get("params")
185
+ json_data = transformed.get("json_data")
186
+ data = transformed.get("data")
187
+ fallback_path = transformed.get("fallback_path")
188
+ fallback_json_data = transformed.get("fallback_json_data")
189
+
190
+ try:
191
+ response = await client.request(
192
+ method=method,
193
+ path=path,
194
+ params=params,
195
+ json_data=json_data,
196
+ data=data,
197
+ project=project,
198
+ )
199
+ except httpx.HTTPStatusError as e:
200
+ if e.response.status_code == 404 and fallback_path:
201
+ logger.info(
202
+ "Endpoint %s returned 404, trying fallback: %s",
203
+ path,
204
+ fallback_path,
205
+ )
206
+ response = await client.request(
207
+ method=method,
208
+ path=fallback_path,
209
+ params=params,
210
+ json_data=fallback_json_data,
211
+ data=data,
212
+ project=project,
213
+ )
214
+ else:
215
+ raise
216
+
217
+ result = self.transform_response(response)
218
+ logger.debug("Tool %s result: %s", self.name, result)
219
+
220
+ return [
221
+ TextContent(
222
+ type="text",
223
+ text=json.dumps(result, indent=2),
224
+ )
225
+ ]
226
+
227
+ except Exception as e:
228
+ error_msg = f"Tool execution failed: {e!s}"
229
+ logger.exception(error_msg)
230
+ return [
231
+ TextContent(
232
+ type="text",
233
+ text=json.dumps({"error": error_msg}),
234
+ )
235
+ ]