Skip to content

Commit 3b3d7f7

Browse files
committed
Add explain structure MCP tool
1 parent bd38470 commit 3b3d7f7

4 files changed

Lines changed: 199 additions & 3 deletions

File tree

docs/mcp-integration.md

Lines changed: 68 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,38 @@ Generate a project structure using specified definition and options.
6969
- `mappings` (optional): Variable mappings for template substitution
7070
- `structures_path` (optional): Custom path to structure definitions
7171

72-
### 4. validate_structure
72+
### 4. explain_structure
73+
Explain how a structure resolves without creating files, fetching remote content, generating prompt-based content, or executing hooks. This is useful when an AI assistant needs to inspect the structure graph before deciding whether to generate it.
74+
75+
```json
76+
{
77+
"name": "explain_structure",
78+
"arguments": {
79+
"structure_definition": "project/python",
80+
"base_path": "/tmp/myproject",
81+
"output": "json",
82+
"variables": {
83+
"project_name": "MyProject"
84+
},
85+
"mappings": {
86+
"team": "platform"
87+
},
88+
"file_strategy": "overwrite",
89+
"structures_path": "/path/to/custom/structures"
90+
}
91+
}
92+
```
93+
94+
**Parameters:**
95+
- `structure_definition` (required): Name or path to the structure definition
96+
- `base_path` (optional): Base path used to resolve generated paths (default: `.`)
97+
- `output` (optional): Output mode - `"text"` or `"json"` (default: `"text"`)
98+
- `variables` (optional): Template variables to resolve structure names, paths, hooks, and nested `with` values
99+
- `mappings` (optional): Additional mappings exposed to templates as `mappings`
100+
- `file_strategy` (optional): Conflict strategy to explain: `overwrite`, `skip`, `append`, `rename`, or `backup` (default: `overwrite`)
101+
- `structures_path` (optional): Custom path to structure definitions
102+
103+
### 5. validate_structure
73104
Validate a structure configuration YAML file.
74105

75106
```json
@@ -204,6 +235,12 @@ async def main():
204235
# FastMCP tools return plain text content
205236
print(result.content[0].text)
206237

238+
explanation = await session.call_tool("explain_structure", {
239+
"structure_definition": "project/python",
240+
"output": "json"
241+
})
242+
print(explanation.content[0].text)
243+
207244
if __name__ == "__main__":
208245
asyncio.run(main())
209246
```
@@ -225,8 +262,9 @@ The MCP tools can be chained together for complex workflows:
225262

226263
1. List available structures
227264
2. Get detailed info about a specific structure
228-
3. Generate the structure with custom mappings
229-
4. Validate any custom configurations
265+
3. Explain the structure to preview files, folders, variables, hooks, and remote references
266+
4. Generate the structure with custom mappings
267+
5. Validate any custom configurations
230268

231269
### Integration Examples
232270

@@ -265,6 +303,32 @@ The MCP tools can be chained together for complex workflows:
265303
}
266304
```
267305

306+
307+
**Example 3: Explain Before Generate**
308+
```json
309+
// 1. Explain structure resolution without side effects
310+
{
311+
"name": "explain_structure",
312+
"arguments": {
313+
"structure_definition": "project/python",
314+
"base_path": "/tmp/review",
315+
"output": "json",
316+
"variables": {
317+
"project_name": "ReviewProject"
318+
}
319+
}
320+
}
321+
322+
// 2. If the explanation looks correct, generate the structure
323+
{
324+
"name": "generate_structure",
325+
"arguments": {
326+
"structure_definition": "project/python",
327+
"base_path": "/tmp/review"
328+
}
329+
}
330+
```
331+
268332
## Configuration
269333

270334
### Environment Variables
@@ -353,6 +417,7 @@ Once connected, you can use these tools:
353417
- `list_structures` - Get all available structures
354418
- `get_structure_info` - Get details about a specific structure
355419
- `generate_structure` - Generate project structures
420+
- `explain_structure` - Explain structure resolution without side effects
356421
- `validate_structure` - Validate YAML configuration files
357422

358423
## Troubleshooting

structkit/commands/mcp.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ def execute(self, args):
4949
print(" - list_structures: List all available structure definitions")
5050
print(" - get_structure_info: Get detailed information about a structure")
5151
print(" - generate_structure: Generate structures with various options")
52+
print(" - explain_structure: Explain structure resolution without side effects")
5253
print(" - validate_structure: Validate structure configuration files")
5354
print("\nExamples:")
5455
print(" structkit mcp --server --transport stdio --debug")

structkit/mcp_server.py

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
2. Getting detailed information about structures
77
3. Generating structures with various options
88
4. Validating structure configurations
9+
5. Explaining structure resolution without side effects
910
"""
1011
import asyncio
1112
import logging
@@ -17,6 +18,7 @@
1718
from fastmcp import FastMCP
1819

1920
from structkit.commands.generate import GenerateCommand
21+
from structkit.commands.explain import ExplainCommand
2022
from structkit.commands.validate import ValidateCommand
2123
from structkit import __version__
2224

@@ -193,6 +195,56 @@ class Args:
193195
finally:
194196
sys.stdout = old
195197

198+
def _explain_structure_logic(
199+
self,
200+
structure_definition: str,
201+
base_path: str = ".",
202+
output: str = "text",
203+
variables: Optional[Dict[str, str]] = None,
204+
mappings: Optional[Dict[str, Any]] = None,
205+
structures_path: Optional[str] = None,
206+
file_strategy: str = "overwrite",
207+
) -> str:
208+
if not structure_definition:
209+
return "Error: structure_definition is required"
210+
211+
valid_outputs = {"text", "json"}
212+
if output not in valid_outputs:
213+
return f"Error: output must be one of {sorted(valid_outputs)}, got: {output}"
214+
215+
valid_file_strategies = {"overwrite", "skip", "append", "rename", "backup"}
216+
if file_strategy not in valid_file_strategies:
217+
return f"Error: file_strategy must be one of {sorted(valid_file_strategies)}, got: {file_strategy}"
218+
219+
class Args:
220+
pass
221+
args = Args()
222+
args.structure_definition = structure_definition
223+
args.base_path = base_path or "."
224+
args.output = output
225+
args.json_output = output == "json"
226+
args.structures_path = structures_path
227+
args.vars = None
228+
args.mappings_file = None
229+
args.file_strategy = file_strategy
230+
args.log = "INFO"
231+
args.config_file = None
232+
args.log_file = None
233+
234+
if variables:
235+
args.vars = ",".join([f"{k}={v}" for k, v in variables.items()])
236+
237+
import argparse
238+
dummy_parser = argparse.ArgumentParser()
239+
command = ExplainCommand(dummy_parser)
240+
explanation = command.explain(args, mappings=mappings or {})
241+
if explanation is None:
242+
return f"Unable to explain structure '{structure_definition}'"
243+
if output == "json":
244+
import json
245+
return json.dumps(explanation, indent=2, sort_keys=True)
246+
return command._format_text(explanation)
247+
196248
# =====================
197249
# FastMCP tool registration (maps to logic above)
198250
# =====================
@@ -255,6 +307,41 @@ async def validate_structure(yaml_file: str) -> str:
255307
self.logger.debug(f"MCP response: validate_structure len={len(result)} preview=\n{preview}")
256308
return result
257309

310+
@self.app.tool(name="explain_structure", description="Explain structure resolution without creating files, fetching remote content, or running hooks")
311+
async def explain_structure(
312+
structure_definition: str,
313+
base_path: str = ".",
314+
output: str = "text",
315+
variables: Optional[Dict[str, str]] = None,
316+
mappings: Optional[Dict[str, Any]] = None,
317+
structures_path: Optional[str] = None,
318+
file_strategy: str = "overwrite",
319+
) -> str:
320+
self.logger.debug(
321+
"MCP request: explain_structure args=%s",
322+
{
323+
"structure_definition": structure_definition,
324+
"base_path": base_path,
325+
"output": output,
326+
"variables": variables,
327+
"mappings": mappings,
328+
"structures_path": structures_path,
329+
"file_strategy": file_strategy,
330+
},
331+
)
332+
result = self._explain_structure_logic(
333+
structure_definition,
334+
base_path,
335+
output,
336+
variables,
337+
mappings,
338+
structures_path,
339+
file_strategy,
340+
)
341+
preview = result if len(result) <= 1000 else result[:1000] + f"... [truncated {len(result)-1000} chars]"
342+
self.logger.debug(f"MCP response: explain_structure len={len(result)} preview=\n{preview}")
343+
return result
344+
258345
async def run(
259346
self,
260347
transport: str = "stdio",

tests/test_mcp_integration.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,49 @@ def test_generate_structure_logic(self):
4343
)
4444
self.assertIsInstance(text, str)
4545

46+
47+
def test_explain_structure_logic(self):
48+
with tempfile.TemporaryDirectory() as temp_dir:
49+
structure_path = os.path.join(temp_dir, 'example.yaml')
50+
with open(structure_path, 'w') as f:
51+
yaml.dump({
52+
'variables': [
53+
{'project_name': {'type': 'string', 'default': 'Demo'}}
54+
],
55+
'pre_hooks': ['echo {{@ project_name @}}'],
56+
'files': [
57+
{'README.md': {'content': '# {{@ project_name @}}'}},
58+
{'remote.txt': {'file': 'https://example.com/template.txt'}}
59+
],
60+
}, f)
61+
62+
text = self.server._explain_structure_logic(
63+
structure_definition=structure_path,
64+
base_path=temp_dir,
65+
variables={'project_name': 'MCP Demo'},
66+
)
67+
68+
self.assertIn('Structure explanation', text)
69+
self.assertIn('MCP Demo', text)
70+
self.assertIn('remote.txt', text)
71+
self.assertIn('https://example.com/template.txt', text)
72+
self.assertIn('Hooks (not executed)', text)
73+
74+
json_text = self.server._explain_structure_logic(
75+
structure_definition=structure_path,
76+
base_path=temp_dir,
77+
output='json',
78+
variables={'project_name': 'MCP Demo'},
79+
file_strategy='skip',
80+
)
81+
self.assertIn('"file_strategy": "skip"', json_text)
82+
self.assertIn('"remote_files"', json_text)
83+
84+
def test_explain_structure_logic_validates_inputs(self):
85+
self.assertIn('structure_definition is required', self.server._explain_structure_logic(''))
86+
self.assertIn('output must be one of', self.server._explain_structure_logic('project/python', output='xml'))
87+
self.assertIn('file_strategy must be one of', self.server._explain_structure_logic('project/python', file_strategy='replace'))
88+
4689
def test_validate_structure_logic(self):
4790
# Missing yaml_file
4891
text = self.server._validate_structure_logic(None)

0 commit comments

Comments
 (0)