Skip to content

Commit 5039804

Browse files
committed
Expose source management through MCP
1 parent 7415c3e commit 5039804

4 files changed

Lines changed: 217 additions & 13 deletions

File tree

docs/custom-structures.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,3 +75,15 @@ Existing custom-structure behavior remains compatible:
7575
4. If none of the above are used, StructKit uses its bundled contrib structures.
7676

7777
When a custom path or named source is selected, StructKit still falls back to bundled contrib structures for `list`, `search`, and `generate` in the same way as the existing `STRUCTKIT_STRUCTURES_PATH` workflow.
78+
79+
### MCP tools
80+
81+
The MCP server exposes the same source-management workflow for AI clients:
82+
83+
- `list_sources`
84+
- `add_source`
85+
- `remove_source`
86+
- `show_source`
87+
- `validate_source`
88+
89+
The structure tools also accept named sources. Pass `source` to `list_structures`, `get_structure_info`, or `generate_structure`, or use a source-prefixed structure name such as `company/project/python` with `get_structure_info` and `generate_structure`.

structkit/commands/mcp.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,11 @@ def execute(self, args):
5050
print(" - get_structure_info: Get detailed information about a structure")
5151
print(" - generate_structure: Generate structures with various options")
5252
print(" - validate_structure: Validate structure configuration files")
53+
print(" - list_sources: List configured custom structure sources")
54+
print(" - add_source: Add or replace a local custom structure source")
55+
print(" - remove_source: Remove a configured custom structure source")
56+
print(" - show_source: Show a configured custom structure source")
57+
print(" - validate_source: Validate a configured custom structure source")
5358
print("\nExamples:")
5459
print(" structkit mcp --server --transport stdio --debug")
5560
print(" structkit mcp --server --transport http --host 127.0.0.1 --port 9000 --path /mcp --uvicorn-log-level debug")

structkit/mcp_server.py

Lines changed: 140 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -6,18 +6,29 @@
66
2. Getting detailed information about structures
77
3. Generating structures with various options
88
4. Validating structure configurations
9+
5. Managing named custom structure sources
910
"""
1011
import asyncio
1112
import logging
1213
import os
1314
import sys
1415
import yaml
16+
from types import SimpleNamespace
1517
from typing import Any, Dict, Optional
1618

1719
from fastmcp import FastMCP
1820

1921
from structkit.commands.generate import GenerateCommand
2022
from structkit.commands.validate import ValidateCommand
23+
from structkit.sources import (
24+
SourceConfigError,
25+
add_source as add_config_source,
26+
get_source_path,
27+
read_sources,
28+
remove_source as remove_config_source,
29+
resolve_structures_path,
30+
validate_source as validate_config_source,
31+
)
2132
from structkit import __version__
2233

2334

@@ -32,13 +43,20 @@ def __init__(self):
3243
# =====================
3344
# Tool logic (transport-agnostic)
3445
# =====================
35-
def _list_structures_logic(self, structures_path: Optional[str] = None) -> str:
46+
def _list_structures_logic(self, structures_path: Optional[str] = None, source: Optional[str] = None) -> str:
3647
this_file = os.path.dirname(os.path.realpath(__file__))
3748
contribs_path = os.path.join(this_file, "contribs")
3849

50+
try:
51+
effective_structures_path, _ = resolve_structures_path(
52+
SimpleNamespace(structures_path=structures_path, source=source)
53+
)
54+
except SourceConfigError as e:
55+
return f"❗ {e}"
56+
3957
paths_to_list = [contribs_path]
40-
if structures_path:
41-
paths_to_list = [structures_path, contribs_path]
58+
if effective_structures_path:
59+
paths_to_list = [effective_structures_path, contribs_path]
4260

4361
all_structures = set()
4462
for path in paths_to_list:
@@ -56,17 +74,28 @@ def _list_structures_logic(self, structures_path: Optional[str] = None) -> str:
5674
result_text += "\n\nNote: Structures with '+' sign are custom structures"
5775
return result_text
5876

59-
def _get_structure_info_logic(self, structure_name: Optional[str], structures_path: Optional[str] = None) -> str:
77+
def _get_structure_info_logic(
78+
self,
79+
structure_name: Optional[str],
80+
structures_path: Optional[str] = None,
81+
source: Optional[str] = None,
82+
) -> str:
6083
if not structure_name:
6184
return "Error: structure_name is required"
6285

6386
# Resolve path
6487
if structure_name.startswith("file://") and structure_name.endswith(".yaml"):
6588
file_path = structure_name[7:]
6689
else:
90+
try:
91+
effective_structures_path, resolved_structure_name = resolve_structures_path(
92+
SimpleNamespace(structures_path=structures_path, source=source), structure_name
93+
)
94+
except SourceConfigError as e:
95+
return f"❗ {e}"
6796
this_file = os.path.dirname(os.path.realpath(__file__))
68-
base = structures_path or os.path.join(this_file, "contribs")
69-
file_path = os.path.join(base, f"{structure_name}.yaml")
97+
base = effective_structures_path or os.path.join(this_file, "contribs")
98+
file_path = os.path.join(base, f"{resolved_structure_name}.yaml")
7099

71100
if not os.path.exists(file_path):
72101
return f"❗ Structure not found: {file_path}"
@@ -119,6 +148,7 @@ def _generate_structure_logic(
119148
dry_run: bool = False,
120149
mappings: Optional[Dict[str, str]] = None,
121150
structures_path: Optional[str] = None,
151+
source: Optional[str] = None,
122152
) -> str:
123153
class Args:
124154
pass
@@ -128,6 +158,7 @@ class Args:
128158
args.output = "console" if output == "console" else "file"
129159
args.dry_run = dry_run
130160
args.structures_path = structures_path
161+
args.source = source
131162
args.vars = None
132163
args.mappings_file = None
133164
args.backup = None
@@ -167,6 +198,54 @@ class Args:
167198
return f"Dry run completed for structure '{structure_definition}' at '{base_path}'"
168199
return f"Structure '{structure_definition}' generated successfully at '{base_path}'"
169200

201+
def _list_sources_logic(self) -> str:
202+
sources = read_sources()
203+
if not sources:
204+
return "No sources configured"
205+
206+
lines = ["📚 Configured structure sources\n"]
207+
for name in sorted(sources):
208+
lines.append(f" - {name}: {sources[name]}\n")
209+
return "".join(lines).rstrip()
210+
211+
def _add_source_logic(self, name: Optional[str], path_or_url: Optional[str]) -> str:
212+
if not name:
213+
return "Error: name is required"
214+
if not path_or_url:
215+
return "Error: path_or_url is required"
216+
try:
217+
source_path = add_config_source(name, path_or_url)
218+
except SourceConfigError as e:
219+
return f"❗ {e}"
220+
return f"Added source '{name}': {source_path}"
221+
222+
def _remove_source_logic(self, name: Optional[str]) -> str:
223+
if not name:
224+
return "Error: name is required"
225+
try:
226+
source_path = remove_config_source(name)
227+
except SourceConfigError as e:
228+
return f"❗ {e}"
229+
return f"Removed source '{name}': {source_path}"
230+
231+
def _show_source_logic(self, name: Optional[str]) -> str:
232+
if not name:
233+
return "Error: name is required"
234+
try:
235+
source_path = get_source_path(name)
236+
except SourceConfigError as e:
237+
return f"❗ {e}"
238+
return f"{name}: {source_path}"
239+
240+
def _validate_source_logic(self, name: Optional[str]) -> str:
241+
if not name:
242+
return "Error: name is required"
243+
try:
244+
source_path = validate_config_source(name)
245+
except SourceConfigError as e:
246+
return f"❗ {e}"
247+
return f"Source '{name}' is valid: {source_path}"
248+
170249
def _validate_structure_logic(self, yaml_file: Optional[str]) -> str:
171250
if not yaml_file:
172251
return "Error: yaml_file is required"
@@ -198,19 +277,23 @@ class Args:
198277
# =====================
199278
def _register_tools(self):
200279
@self.app.tool(name="list_structures", description="List all available structure definitions")
201-
async def list_structures(structures_path: Optional[str] = None) -> str:
202-
self.logger.debug(f"MCP request: list_structures args={{'structures_path': {structures_path!r}}}")
203-
result = self._list_structures_logic(structures_path)
280+
async def list_structures(structures_path: Optional[str] = None, source: Optional[str] = None) -> str:
281+
self.logger.debug(
282+
f"MCP request: list_structures args={{'structures_path': {structures_path!r}, 'source': {source!r}}}"
283+
)
284+
result = self._list_structures_logic(structures_path, source)
204285
preview = result if len(result) <= 1000 else result[:1000] + f"... [truncated {len(result)-1000} chars]"
205286
self.logger.debug(f"MCP response: list_structures len={len(result)} preview=\n{preview}")
206287
return result
207288

208289
@self.app.tool(name="get_structure_info", description="Get detailed information about a specific structure")
209-
async def get_structure_info(structure_name: str, structures_path: Optional[str] = None) -> str:
290+
async def get_structure_info(
291+
structure_name: str, structures_path: Optional[str] = None, source: Optional[str] = None
292+
) -> str:
210293
self.logger.debug(
211-
f"MCP request: get_structure_info args={{'structure_name': {structure_name!r}, 'structures_path': {structures_path!r}}}"
294+
f"MCP request: get_structure_info args={{'structure_name': {structure_name!r}, 'structures_path': {structures_path!r}, 'source': {source!r}}}"
212295
)
213-
result = self._get_structure_info_logic(structure_name, structures_path)
296+
result = self._get_structure_info_logic(structure_name, structures_path, source)
214297
preview = result if len(result) <= 1000 else result[:1000] + f"... [truncated {len(result)-1000} chars]"
215298
self.logger.debug(f"MCP response: get_structure_info len={len(result)} preview=\n{preview}")
216299
return result
@@ -223,6 +306,7 @@ async def generate_structure(
223306
dry_run: bool = False,
224307
mappings: Optional[Dict[str, str]] = None,
225308
structures_path: Optional[str] = None,
309+
source: Optional[str] = None,
226310
) -> str:
227311
self.logger.debug(
228312
"MCP request: generate_structure args=%s",
@@ -233,6 +317,7 @@ async def generate_structure(
233317
"dry_run": dry_run,
234318
"mappings": mappings,
235319
"structures_path": structures_path,
320+
"source": source,
236321
},
237322
)
238323
result = self._generate_structure_logic(
@@ -242,11 +327,52 @@ async def generate_structure(
242327
dry_run,
243328
mappings,
244329
structures_path,
330+
source,
245331
)
246332
preview = result if len(result) <= 1000 else result[:1000] + f"... [truncated {len(result)-1000} chars]"
247333
self.logger.debug(f"MCP response: generate_structure len={len(result)} preview=\n{preview}")
248334
return result
249335

336+
@self.app.tool(name="list_sources", description="List configured custom structure sources")
337+
async def list_sources() -> str:
338+
self.logger.debug("MCP request: list_sources args={}")
339+
result = self._list_sources_logic()
340+
preview = result if len(result) <= 1000 else result[:1000] + f"... [truncated {len(result)-1000} chars]"
341+
self.logger.debug(f"MCP response: list_sources len={len(result)} preview=\n{preview}")
342+
return result
343+
344+
@self.app.tool(name="add_source", description="Add or replace a local custom structure source")
345+
async def add_source(name: str, path_or_url: str) -> str:
346+
self.logger.debug(f"MCP request: add_source args={{'name': {name!r}, 'path_or_url': {path_or_url!r}}}")
347+
result = self._add_source_logic(name, path_or_url)
348+
preview = result if len(result) <= 1000 else result[:1000] + f"... [truncated {len(result)-1000} chars]"
349+
self.logger.debug(f"MCP response: add_source len={len(result)} preview=\n{preview}")
350+
return result
351+
352+
@self.app.tool(name="remove_source", description="Remove a configured custom structure source")
353+
async def remove_source(name: str) -> str:
354+
self.logger.debug(f"MCP request: remove_source args={{'name': {name!r}}}")
355+
result = self._remove_source_logic(name)
356+
preview = result if len(result) <= 1000 else result[:1000] + f"... [truncated {len(result)-1000} chars]"
357+
self.logger.debug(f"MCP response: remove_source len={len(result)} preview=\n{preview}")
358+
return result
359+
360+
@self.app.tool(name="show_source", description="Show a configured custom structure source")
361+
async def show_source(name: str) -> str:
362+
self.logger.debug(f"MCP request: show_source args={{'name': {name!r}}}")
363+
result = self._show_source_logic(name)
364+
preview = result if len(result) <= 1000 else result[:1000] + f"... [truncated {len(result)-1000} chars]"
365+
self.logger.debug(f"MCP response: show_source len={len(result)} preview=\n{preview}")
366+
return result
367+
368+
@self.app.tool(name="validate_source", description="Validate a configured custom structure source")
369+
async def validate_source(name: str) -> str:
370+
self.logger.debug(f"MCP request: validate_source args={{'name': {name!r}}}")
371+
result = self._validate_source_logic(name)
372+
preview = result if len(result) <= 1000 else result[:1000] + f"... [truncated {len(result)-1000} chars]"
373+
self.logger.debug(f"MCP response: validate_source len={len(result)} preview=\n{preview}")
374+
return result
375+
250376
@self.app.tool(name="validate_structure", description="Validate a structure configuration YAML file")
251377
async def validate_structure(yaml_file: str) -> str:
252378
self.logger.debug(f"MCP request: validate_structure args={{'yaml_file': {yaml_file!r}}}")
@@ -323,8 +449,9 @@ async def _handle_get_structure_info(self, params: Dict[str, Any]):
323449
"""Compatibility method for tests that expect MCP-style responses."""
324450
structure_name = params.get('structure_name')
325451
structures_path = params.get('structures_path')
452+
source = params.get('source')
326453

327-
result_text = self._get_structure_info_logic(structure_name, structures_path)
454+
result_text = self._get_structure_info_logic(structure_name, structures_path, source)
328455

329456
# Mock MCP response structure
330457
class MockContent:

tests/test_mcp_integration.py

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

46+
47+
def test_source_management_logic(self):
48+
with tempfile.TemporaryDirectory() as temp_dir:
49+
config_path = os.path.join(temp_dir, 'sources.json')
50+
source_dir = os.path.join(temp_dir, 'source')
51+
os.makedirs(source_dir)
52+
53+
from unittest.mock import patch
54+
with patch.dict(os.environ, {'STRUCTKIT_SOURCES_CONFIG': config_path}):
55+
self.assertEqual(self.server._list_sources_logic(), 'No sources configured')
56+
57+
added = self.server._add_source_logic('company', source_dir)
58+
self.assertIn("Added source 'company'", added)
59+
60+
listed = self.server._list_sources_logic()
61+
self.assertIn('company', listed)
62+
self.assertIn(source_dir, listed)
63+
64+
shown = self.server._show_source_logic('company')
65+
self.assertIn(source_dir, shown)
66+
67+
validated = self.server._validate_source_logic('company')
68+
self.assertIn("Source 'company' is valid", validated)
69+
70+
removed = self.server._remove_source_logic('company')
71+
self.assertIn("Removed source 'company'", removed)
72+
73+
def test_source_management_logic_errors(self):
74+
with tempfile.TemporaryDirectory() as temp_dir:
75+
config_path = os.path.join(temp_dir, 'sources.json')
76+
77+
from unittest.mock import patch
78+
with patch.dict(os.environ, {'STRUCTKIT_SOURCES_CONFIG': config_path}):
79+
self.assertIn('name is required', self.server._add_source_logic(None, temp_dir))
80+
self.assertIn('path_or_url is required', self.server._add_source_logic('company', None))
81+
self.assertIn('Only local filesystem', self.server._add_source_logic('remote', 'https://example.com/templates.git'))
82+
self.assertIn('Unknown source', self.server._show_source_logic('missing'))
83+
84+
def test_structure_logic_accepts_named_source(self):
85+
with tempfile.TemporaryDirectory() as temp_dir:
86+
config_path = os.path.join(temp_dir, 'sources.json')
87+
source_dir = os.path.join(temp_dir, 'source')
88+
os.makedirs(os.path.join(source_dir, 'project'))
89+
structure_file = os.path.join(source_dir, 'project', 'python.yaml')
90+
with open(structure_file, 'w') as f:
91+
yaml.dump({'description': 'Custom Python structure', 'files': []}, f)
92+
93+
from unittest.mock import patch
94+
with patch.dict(os.environ, {'STRUCTKIT_SOURCES_CONFIG': config_path}):
95+
self.server._add_source_logic('company', source_dir)
96+
97+
listed = self.server._list_structures_logic(source='company')
98+
self.assertIn('+ project/python', listed)
99+
100+
explicit = self.server._get_structure_info_logic('project/python', source='company')
101+
self.assertIn('Custom Python structure', explicit)
102+
103+
prefixed = self.server._get_structure_info_logic('company/project/python')
104+
self.assertIn('Custom Python structure', prefixed)
105+
46106
def test_validate_structure_logic(self):
47107
# Missing yaml_file
48108
text = self.server._validate_structure_logic(None)

0 commit comments

Comments
 (0)