dropmcp 0.1.0__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.
@@ -0,0 +1,21 @@
1
+ This server exposes a curated set of **skills** and **prompts** loaded from a
2
+ filesystem. Skills are returned as tools; prompts are templated messages you
3
+ invoke by name.
4
+
5
+ ### Skills
6
+
7
+ Check this server's skills when the user is working on a task one of them
8
+ covers:
9
+
10
+ {{INSTRUCTION_SUMMARIES}}
11
+
12
+ Each skill tool returns its full instructions plus resource links to any
13
+ supporting files (scripts, templates, references). Call the tool, then follow
14
+ the returned instructions.
15
+
16
+ ### Prompts
17
+
18
+ This server also exposes prompts — templated messages you can invoke with
19
+ arguments:
20
+
21
+ {{PROMPT_SUMMARIES}}
dropmcp/__init__.py ADDED
@@ -0,0 +1,110 @@
1
+ """dropmcp — drop a skills/ and prompts/ folder, get a FastMCP server.
2
+
3
+ Quick start::
4
+
5
+ import dropmcp
6
+ dropmcp.run(skills="skills", prompts="prompts")
7
+
8
+ Or grab the server to add your own routes/middleware first::
9
+
10
+ mcp = dropmcp.create_server(skills="skills", prompts="prompts")
11
+ # ... customise ...
12
+ mcp.run(transport="streamable-http", host="0.0.0.0", port=8000)
13
+
14
+ dropmcp serves over streamable-HTTP only — it exists to *share* skills with
15
+ remote clients, not to run locally over stdio.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from pathlib import Path
21
+
22
+ from fastmcp import FastMCP
23
+
24
+ from dropmcp.config import Settings
25
+ from dropmcp.server import build_server
26
+
27
+ __all__ = ["create_server", "run", "Settings"]
28
+
29
+ __version__ = "0.1.0"
30
+
31
+ _HTTP_TRANSPORT = "streamable-http"
32
+
33
+
34
+ def create_server(
35
+ *,
36
+ skills: str | Path | None = None,
37
+ prompts: str | Path | None = None,
38
+ catalog_defaults: str | Path | None = None,
39
+ instructions: str | Path | None = None,
40
+ name: str | None = None,
41
+ website_url: str | None = None,
42
+ icon: str | Path | None = None,
43
+ host: str | None = None,
44
+ port: int | None = None,
45
+ ui_enabled: bool | None = None,
46
+ reload: bool | None = None,
47
+ ) -> FastMCP:
48
+ """Build and return a configured `FastMCP` server without running it.
49
+
50
+ Use this when you want to attach custom routes or middleware before
51
+ serving. Settings are resolved from these kwargs, then `DROPMCP_*`
52
+ environment variables, then defaults.
53
+ """
54
+ settings = Settings.resolve(
55
+ skills=skills,
56
+ prompts=prompts,
57
+ catalog_defaults=catalog_defaults,
58
+ instructions=instructions,
59
+ name=name,
60
+ website_url=website_url,
61
+ icon=icon,
62
+ host=host,
63
+ port=port,
64
+ ui_enabled=ui_enabled,
65
+ reload=reload,
66
+ )
67
+ return build_server(settings)
68
+
69
+
70
+ def run(
71
+ *,
72
+ skills: str | Path | None = None,
73
+ prompts: str | Path | None = None,
74
+ catalog_defaults: str | Path | None = None,
75
+ instructions: str | Path | None = None,
76
+ name: str | None = None,
77
+ website_url: str | None = None,
78
+ icon: str | Path | None = None,
79
+ host: str | None = None,
80
+ port: int | None = None,
81
+ ui_enabled: bool | None = None,
82
+ reload: bool | None = None,
83
+ ) -> None:
84
+ """Build the server and serve it over streamable-HTTP.
85
+
86
+ Bind address is controlled by ``host``/``port`` (or ``DROPMCP_HOST`` /
87
+ ``DROPMCP_PORT``). The catalog UI is served at ``/`` and the MCP endpoint
88
+ at ``/mcp``.
89
+ """
90
+ settings = Settings.resolve(
91
+ skills=skills,
92
+ prompts=prompts,
93
+ catalog_defaults=catalog_defaults,
94
+ instructions=instructions,
95
+ name=name,
96
+ website_url=website_url,
97
+ icon=icon,
98
+ host=host,
99
+ port=port,
100
+ ui_enabled=ui_enabled,
101
+ reload=reload,
102
+ )
103
+ mcp = build_server(settings)
104
+
105
+ mcp.run(
106
+ transport=_HTTP_TRANSPORT,
107
+ host=settings.host,
108
+ port=settings.port,
109
+ stateless_http=True,
110
+ )
dropmcp/__main__.py ADDED
@@ -0,0 +1,14 @@
1
+ """``python -m dropmcp`` — serve a dropmcp server configured entirely from
2
+ ``DROPMCP_*`` environment variables.
3
+
4
+ Handy for container / env-only deployments where you don't want to write a
5
+ ``server.py``. There are no command-line options: dropmcp is a hosted
6
+ streamable-HTTP server, so everything is driven by the environment.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import dropmcp
12
+
13
+ if __name__ == "__main__":
14
+ dropmcp.run()
dropmcp/catalog.py ADDED
@@ -0,0 +1,285 @@
1
+ """Discover catalog metadata and asset paths from skills/ and prompts/ layouts.
2
+
3
+ Skills: skills/{skill-name}/SKILL.md with optional catalog/ assets.
4
+ Prompts: prompts/{prompt-name}/PROMPT.md with optional catalog/ assets.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ import re
11
+ from dataclasses import dataclass
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ import yaml
16
+
17
+ log = logging.getLogger(__name__)
18
+
19
+ SKILL_FILE = "SKILL.md"
20
+ PROMPT_FILE = "PROMPT.md"
21
+ CATALOG_DIR = "catalog"
22
+ SCREENSHOTS_DIR = "screenshots"
23
+ EXAMPLES_DIR = "examples"
24
+
25
+ FRONTMATTER_RE = re.compile(r"^---\s*\n(.*?)\n---\s*\n", re.DOTALL)
26
+
27
+ IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".svg", ".webp"}
28
+
29
+
30
+ def _find_image(directory: Path, stem: str) -> Path | None:
31
+ for ext in sorted(IMAGE_EXTENSIONS):
32
+ candidate = directory / f"{stem}{ext}"
33
+ if candidate.is_file():
34
+ return candidate
35
+ return None
36
+
37
+
38
+ def _parse_frontmatter_meta(path: Path) -> dict[str, Any]:
39
+ raw = path.read_text(encoding="utf-8")
40
+ match = FRONTMATTER_RE.match(raw)
41
+ if not match:
42
+ raise ValueError(f"{path}: missing YAML frontmatter")
43
+ return yaml.safe_load(match.group(1)) or {}
44
+
45
+
46
+ def _list_screenshot_filenames(screenshots_dir: Path) -> list[str]:
47
+ if not screenshots_dir.is_dir():
48
+ return []
49
+ names: list[str] = []
50
+ for p in screenshots_dir.iterdir():
51
+ if (
52
+ p.is_file()
53
+ and not p.name.startswith(".")
54
+ and p.suffix.lower() in IMAGE_EXTENSIONS
55
+ ):
56
+ names.append(p.name)
57
+ return sorted(names)
58
+
59
+
60
+ def _list_example_filenames(examples_dir: Path) -> list[str]:
61
+ if not examples_dir.is_dir():
62
+ return []
63
+ names: list[str] = []
64
+ for p in examples_dir.iterdir():
65
+ if p.is_file() and not p.name.startswith("."):
66
+ names.append(p.name)
67
+ return sorted(names)
68
+
69
+
70
+ def _safe_file_in_subdir(parent: Path, filename: str) -> Path | None:
71
+ if not filename or filename in (".", ".."):
72
+ return None
73
+ if Path(filename).name != filename:
74
+ return None
75
+ base = parent.resolve()
76
+ path = (parent / filename).resolve()
77
+ try:
78
+ path.relative_to(base)
79
+ except ValueError:
80
+ return None
81
+ return path if path.is_file() else None
82
+
83
+
84
+ def _inspect_catalog(catalog_dir: Path) -> tuple[bool, bool, list[str], list[str]]:
85
+ if not catalog_dir.is_dir():
86
+ return False, False, [], []
87
+ hero = _find_image(catalog_dir, "hero")
88
+ thumb = _find_image(catalog_dir, "thumbnail")
89
+ screenshots = _list_screenshot_filenames(catalog_dir / SCREENSHOTS_DIR)
90
+ examples = _list_example_filenames(catalog_dir / EXAMPLES_DIR)
91
+ return hero is not None, thumb is not None, screenshots, examples
92
+
93
+
94
+ @dataclass
95
+ class CatalogEntry:
96
+ name: str
97
+ type: str
98
+ category: str
99
+ description: str
100
+ arguments: list[dict]
101
+ has_hero: bool
102
+ has_thumbnail: bool
103
+ screenshot_filenames: list[str]
104
+ example_filenames: list[str]
105
+ dir_path: Path
106
+ item_dir: Path
107
+
108
+
109
+ class CatalogProvider:
110
+ """Discovers catalog assets from skills and prompts directories."""
111
+
112
+ def __init__(
113
+ self,
114
+ skills_dir: Path,
115
+ prompts_dir: Path,
116
+ defaults_dir: Path,
117
+ *,
118
+ reload: bool = False,
119
+ ) -> None:
120
+ self._skills_dir = Path(skills_dir).resolve()
121
+ self._prompts_dir = Path(prompts_dir).resolve()
122
+ self._defaults_dir = Path(defaults_dir).resolve()
123
+ self._reload = reload
124
+ self._entries: list[CatalogEntry] | None = None
125
+
126
+ def _discover(self) -> list[CatalogEntry]:
127
+ entries: list[CatalogEntry] = []
128
+ entries.extend(self._discover_skills())
129
+ entries.extend(self._discover_prompts())
130
+ entries.sort(key=lambda e: (e.type, e.name))
131
+ return entries
132
+
133
+ def _discover_skills(self) -> list[CatalogEntry]:
134
+ found: list[CatalogEntry] = []
135
+ if not self._skills_dir.is_dir():
136
+ return found
137
+
138
+ for skill_dir in sorted(self._skills_dir.iterdir()):
139
+ if not skill_dir.is_dir():
140
+ continue
141
+ main_file = skill_dir / SKILL_FILE
142
+ if not main_file.is_file():
143
+ continue
144
+ try:
145
+ meta = _parse_frontmatter_meta(main_file)
146
+ name = str(meta["name"])
147
+ category = str(meta.get("category", ""))
148
+ description = str(meta.get("description", ""))
149
+ item_dir = skill_dir.resolve()
150
+ catalog_dir = (item_dir / CATALOG_DIR).resolve()
151
+ has_hero, has_thumb, shots, examples = _inspect_catalog(catalog_dir)
152
+ found.append(
153
+ CatalogEntry(
154
+ name=name,
155
+ type="skill",
156
+ category=category,
157
+ description=description,
158
+ arguments=[],
159
+ has_hero=has_hero,
160
+ has_thumbnail=has_thumb,
161
+ screenshot_filenames=shots,
162
+ example_filenames=examples,
163
+ dir_path=catalog_dir,
164
+ item_dir=item_dir,
165
+ )
166
+ )
167
+ except Exception as exc:
168
+ log.warning("Skipping skill %s: %s", skill_dir, exc)
169
+ return found
170
+
171
+ def _discover_prompts(self) -> list[CatalogEntry]:
172
+ found: list[CatalogEntry] = []
173
+ if not self._prompts_dir.is_dir():
174
+ return found
175
+
176
+ for prompt_dir in sorted(self._prompts_dir.iterdir()):
177
+ if not prompt_dir.is_dir():
178
+ continue
179
+ main_file = prompt_dir / PROMPT_FILE
180
+ if not main_file.is_file():
181
+ continue
182
+ try:
183
+ meta = _parse_frontmatter_meta(main_file)
184
+ name = str(meta["name"])
185
+ description = str(meta.get("description", ""))
186
+ arguments = meta.get("arguments", [])
187
+ if not isinstance(arguments, list):
188
+ arguments = []
189
+ else:
190
+ arguments = [a for a in arguments if isinstance(a, dict)]
191
+ item_dir = prompt_dir.resolve()
192
+ catalog_dir = (item_dir / CATALOG_DIR).resolve()
193
+ has_hero, has_thumb, shots, examples = _inspect_catalog(catalog_dir)
194
+ found.append(
195
+ CatalogEntry(
196
+ name=name,
197
+ type="prompt",
198
+ category="prompts",
199
+ description=description,
200
+ arguments=arguments,
201
+ has_hero=has_hero,
202
+ has_thumbnail=has_thumb,
203
+ screenshot_filenames=shots,
204
+ example_filenames=examples,
205
+ dir_path=catalog_dir,
206
+ item_dir=item_dir,
207
+ )
208
+ )
209
+ except Exception as exc:
210
+ log.warning("Skipping prompt %s: %s", prompt_dir, exc)
211
+ return found
212
+
213
+ def _ensure_discovered(self) -> None:
214
+ if self._reload or self._entries is None:
215
+ self._entries = self._discover()
216
+
217
+ def get_entries(self) -> list[CatalogEntry]:
218
+ self._ensure_discovered()
219
+ return list(self._entries or [])
220
+
221
+ def get_entry(self, item_type: str, name: str) -> CatalogEntry | None:
222
+ self._ensure_discovered()
223
+ t = item_type.lower()
224
+ for e in self._entries or []:
225
+ if e.type == t and e.name == name:
226
+ return e
227
+ return None
228
+
229
+ def resolve_image_path(
230
+ self,
231
+ item_type: str,
232
+ name: str,
233
+ image_kind: str,
234
+ filename: str | None = None,
235
+ ) -> Path | None:
236
+ entry = self.get_entry(item_type, name)
237
+ if entry is None:
238
+ return None
239
+ catalog_dir = entry.dir_path
240
+ kind = image_kind.lower()
241
+ if kind == "hero":
242
+ return _find_image(catalog_dir, "hero")
243
+ if kind == "thumbnail":
244
+ return _find_image(catalog_dir, "thumbnail")
245
+ if kind == "screenshot":
246
+ if not filename:
247
+ return None
248
+ shots = catalog_dir / SCREENSHOTS_DIR
249
+ return _safe_file_in_subdir(shots, filename)
250
+ return None
251
+
252
+ def resolve_example_path(
253
+ self, item_type: str, name: str, filename: str
254
+ ) -> Path | None:
255
+ entry = self.get_entry(item_type, name)
256
+ if entry is None:
257
+ return None
258
+ catalog_dir = entry.dir_path
259
+ examples = catalog_dir / EXAMPLES_DIR
260
+ return _safe_file_in_subdir(examples, filename)
261
+
262
+ def resolve_thumbnail_path(self, item_type: str, name: str) -> Path | None:
263
+ """Resolve thumbnail with fallback: thumbnail.* -> hero.* -> defaults/{category}.* -> defaults/default.svg"""
264
+ entry = self.get_entry(item_type, name)
265
+ if entry is None:
266
+ return None
267
+ catalog_dir = entry.dir_path
268
+
269
+ thumb = _find_image(catalog_dir, "thumbnail")
270
+ if thumb is not None:
271
+ return thumb
272
+
273
+ hero = _find_image(catalog_dir, "hero")
274
+ if hero is not None:
275
+ return hero
276
+
277
+ if self._defaults_dir.is_dir():
278
+ fallback = _find_image(self._defaults_dir, entry.category)
279
+ if fallback is not None:
280
+ return fallback
281
+ default_svg = self._defaults_dir / "default.svg"
282
+ if default_svg.is_file():
283
+ return default_svg
284
+
285
+ return None
dropmcp/config.py ADDED
@@ -0,0 +1,156 @@
1
+ """Runtime configuration for a dropmcp server.
2
+
3
+ A single `Settings` object holds everything the server needs. Values are
4
+ resolved in priority order: explicit keyword arguments, then environment
5
+ variables (`DROPMCP_*`), then sensible defaults. This keeps the one-liner
6
+ (`dropmcp.run(skills="skills", prompts="prompts")`) ergonomic while letting
7
+ hosted deployments override anything via the environment.
8
+
9
+ dropmcp serves over streamable-HTTP only — it exists to *share* skills with
10
+ multiple remote clients, so there is no local stdio transport to configure.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import os
16
+ from dataclasses import dataclass
17
+ from importlib import resources
18
+ from pathlib import Path
19
+
20
+ _TRUE = {"1", "true", "yes", "on"}
21
+ _FALSE = {"0", "false", "no", "off"}
22
+
23
+ DEFAULT_NAME = "dropmcp"
24
+ DEFAULT_SKILLS_DIR = "skills"
25
+ DEFAULT_PROMPTS_DIR = "prompts"
26
+ DEFAULT_HOST = "127.0.0.1"
27
+ DEFAULT_PORT = 8000
28
+ INSTRUCTIONS_FILENAME = "INSTRUCTIONS.md"
29
+ DEFAULT_INSTRUCTIONS_RESOURCE = "INSTRUCTIONS.default.md"
30
+ DEFAULT_ICON_RESOURCE = "static/icon.svg"
31
+
32
+
33
+ def _env(name: str) -> str | None:
34
+ value = os.environ.get(name)
35
+ return value if value else None
36
+
37
+
38
+ def _env_bool(name: str) -> bool | None:
39
+ value = os.environ.get(name)
40
+ if value is None:
41
+ return None
42
+ lowered = value.strip().lower()
43
+ if lowered in _TRUE:
44
+ return True
45
+ if lowered in _FALSE:
46
+ return False
47
+ return None
48
+
49
+
50
+ def _first(*candidates):
51
+ for candidate in candidates:
52
+ if candidate is not None:
53
+ return candidate
54
+ return None
55
+
56
+
57
+ def _packaged_default_instructions() -> Path:
58
+ return Path(resources.files("dropmcp") / DEFAULT_INSTRUCTIONS_RESOURCE)
59
+
60
+
61
+ def _packaged_default_icon() -> Path:
62
+ return Path(resources.files("dropmcp") / DEFAULT_ICON_RESOURCE)
63
+
64
+
65
+ def _resolve_instructions_path(
66
+ explicit: str | Path | None,
67
+ skills_dir: Path,
68
+ ) -> Path:
69
+ """Pick the INSTRUCTIONS template: explicit -> env -> cwd -> packaged default.
70
+
71
+ The cwd lookup means a user who drops an `INSTRUCTIONS.md` next to their
72
+ `skills/` and `prompts/` folders gets it picked up automatically.
73
+ """
74
+ chosen = _first(explicit, _env("DROPMCP_INSTRUCTIONS"))
75
+ if chosen is not None:
76
+ return Path(chosen)
77
+
78
+ for candidate in (Path.cwd() / INSTRUCTIONS_FILENAME, skills_dir.parent / INSTRUCTIONS_FILENAME):
79
+ if candidate.is_file():
80
+ return candidate
81
+
82
+ return _packaged_default_instructions()
83
+
84
+
85
+ def _resolve_icon_path(
86
+ explicit: str | Path | None,
87
+ skills_dir: Path,
88
+ ) -> Path:
89
+ chosen = _first(explicit, _env("DROPMCP_ICON"))
90
+ if chosen is not None:
91
+ return Path(chosen)
92
+
93
+ for candidate in (Path.cwd() / "icon.svg", skills_dir.parent / "icon.svg"):
94
+ if candidate.is_file():
95
+ return candidate
96
+
97
+ return _packaged_default_icon()
98
+
99
+
100
+ @dataclass(frozen=True)
101
+ class Settings:
102
+ skills_dir: Path
103
+ prompts_dir: Path
104
+ catalog_defaults_dir: Path | None
105
+ instructions_path: Path
106
+ name: str
107
+ website_url: str | None
108
+ icon: Path
109
+ host: str
110
+ port: int
111
+ ui_enabled: bool
112
+ reload: bool
113
+
114
+ @classmethod
115
+ def resolve(
116
+ cls,
117
+ *,
118
+ skills: str | Path | None = None,
119
+ prompts: str | Path | None = None,
120
+ catalog_defaults: str | Path | None = None,
121
+ instructions: str | Path | None = None,
122
+ name: str | None = None,
123
+ website_url: str | None = None,
124
+ icon: str | Path | None = None,
125
+ host: str | None = None,
126
+ port: int | None = None,
127
+ ui_enabled: bool | None = None,
128
+ reload: bool | None = None,
129
+ ) -> "Settings":
130
+ skills_dir = Path(
131
+ _first(skills, _env("DROPMCP_SKILLS"), DEFAULT_SKILLS_DIR)
132
+ )
133
+ prompts_dir = Path(
134
+ _first(prompts, _env("DROPMCP_PROMPTS"), DEFAULT_PROMPTS_DIR)
135
+ )
136
+
137
+ catalog_defaults_raw = _first(catalog_defaults, _env("DROPMCP_CATALOG_DEFAULTS"))
138
+ catalog_defaults_dir = (
139
+ Path(catalog_defaults_raw) if catalog_defaults_raw is not None else None
140
+ )
141
+
142
+ port_raw = _first(port, _env("DROPMCP_PORT"), DEFAULT_PORT)
143
+
144
+ return cls(
145
+ skills_dir=skills_dir,
146
+ prompts_dir=prompts_dir,
147
+ catalog_defaults_dir=catalog_defaults_dir,
148
+ instructions_path=_resolve_instructions_path(instructions, skills_dir),
149
+ name=_first(name, _env("DROPMCP_NAME"), DEFAULT_NAME),
150
+ website_url=_first(website_url, _env("DROPMCP_WEBSITE_URL")),
151
+ icon=_resolve_icon_path(icon, skills_dir),
152
+ host=_first(host, _env("DROPMCP_HOST"), DEFAULT_HOST),
153
+ port=int(port_raw),
154
+ ui_enabled=_first(ui_enabled, _env_bool("DROPMCP_UI"), True),
155
+ reload=_first(reload, _env_bool("DROPMCP_RELOAD"), False),
156
+ )
@@ -0,0 +1,121 @@
1
+ """Aggregate per-skill / per-prompt `instruction_summary` frontmatter into the
2
+ server-level instructions string the MCP client sees.
3
+
4
+ Each skill or prompt can declare a short phrase (or list of phrases) in its
5
+ YAML frontmatter under `instruction_summary`. At server startup we collect
6
+ them all and substitute them into `INSTRUCTIONS.md` wherever the
7
+ `{{INSTRUCTION_SUMMARIES}}` (skills) and `{{PROMPT_SUMMARIES}}` (prompts)
8
+ placeholders appear, rendered as markdown bullet lists. The placeholders
9
+ let the rest of `INSTRUCTIONS.md` stay hand-written while the bullet lists
10
+ stay in lockstep with whatever is currently installed under `skills/` and
11
+ `prompts/`.
12
+
13
+ If a placeholder is absent the template is returned unchanged for that
14
+ section, so existing deployments that haven't adopted a placeholder still
15
+ work.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import logging
21
+ import re
22
+ from pathlib import Path
23
+
24
+ import yaml
25
+
26
+ log = logging.getLogger(__name__)
27
+
28
+ SKILLS_PLACEHOLDER = "{{INSTRUCTION_SUMMARIES}}"
29
+ PROMPTS_PLACEHOLDER = "{{PROMPT_SUMMARIES}}"
30
+
31
+ _FRONTMATTER_RE = re.compile(r"^---\s*\n(.*?)\n---\s*\n", re.DOTALL)
32
+
33
+
34
+ def _parse_frontmatter(path: Path) -> dict:
35
+ raw = path.read_text(encoding="utf-8")
36
+ match = _FRONTMATTER_RE.match(raw)
37
+ if not match:
38
+ return {}
39
+ try:
40
+ return yaml.safe_load(match.group(1)) or {}
41
+ except yaml.YAMLError as exc:
42
+ log.warning("Failed to parse frontmatter for %s: %s", path, exc)
43
+ return {}
44
+
45
+
46
+ def _extract_summaries(meta: dict) -> list[str]:
47
+ value = meta.get("instruction_summary")
48
+ if value is None:
49
+ return []
50
+ if isinstance(value, str):
51
+ text = value.strip()
52
+ return [text] if text else []
53
+ if isinstance(value, list):
54
+ return [str(item).strip() for item in value if str(item).strip()]
55
+ return []
56
+
57
+
58
+ def _collect(root: Path, main_file: str) -> list[tuple[str, str]]:
59
+ """Return `(name, summary)` pairs for every entry under `root`.
60
+
61
+ `name` is taken from the YAML frontmatter; if absent we fall back to the
62
+ directory name so the bullet still has something the agent can call.
63
+ """
64
+ if not root.is_dir():
65
+ return []
66
+ pairs: list[tuple[str, str]] = []
67
+ for sub in sorted(root.iterdir()):
68
+ f = sub / main_file
69
+ if not f.is_file():
70
+ continue
71
+ meta = _parse_frontmatter(f)
72
+ name = str(meta.get("name") or sub.name).strip()
73
+ for summary in _extract_summaries(meta):
74
+ pairs.append((name, summary))
75
+ return pairs
76
+
77
+
78
+ def _render_bullets(
79
+ pairs: list[tuple[str, str]],
80
+ empty_message: str,
81
+ ) -> str:
82
+ if not pairs:
83
+ return empty_message
84
+ return "\n".join(f"- `{name}` — {summary}" for name, summary in pairs)
85
+
86
+
87
+ def build_server_instructions(
88
+ template_path: Path,
89
+ skills_dir: Path,
90
+ prompts_dir: Path,
91
+ ) -> str | None:
92
+ """Read `INSTRUCTIONS.md` and substitute the summaries placeholders.
93
+
94
+ `{{INSTRUCTION_SUMMARIES}}` is replaced with a bullet list of skill
95
+ `instruction_summary` values; `{{PROMPT_SUMMARIES}}` is replaced with the
96
+ same for prompts. Returns None if the template file is missing so callers
97
+ can pass `None` through to FastMCP (which treats it as "no instructions").
98
+ """
99
+ if not template_path.exists():
100
+ return None
101
+ template = template_path.read_text(encoding="utf-8").strip()
102
+
103
+ if SKILLS_PLACEHOLDER in template:
104
+ template = template.replace(
105
+ SKILLS_PLACEHOLDER,
106
+ _render_bullets(
107
+ _collect(skills_dir, "SKILL.md"),
108
+ "_(no skills have declared an instruction_summary yet)_",
109
+ ),
110
+ )
111
+
112
+ if PROMPTS_PLACEHOLDER in template:
113
+ template = template.replace(
114
+ PROMPTS_PLACEHOLDER,
115
+ _render_bullets(
116
+ _collect(prompts_dir, "PROMPT.md"),
117
+ "_(no prompts have declared an instruction_summary yet)_",
118
+ ),
119
+ )
120
+
121
+ return template