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.
- dropmcp/INSTRUCTIONS.default.md +21 -0
- dropmcp/__init__.py +110 -0
- dropmcp/__main__.py +14 -0
- dropmcp/catalog.py +285 -0
- dropmcp/config.py +156 -0
- dropmcp/instructions.py +121 -0
- dropmcp/middleware.py +97 -0
- dropmcp/prompts.py +152 -0
- dropmcp/server.py +223 -0
- dropmcp/skills.py +218 -0
- dropmcp/static/dist/assets/index-BRIiqtZ6.css +1 -0
- dropmcp/static/dist/assets/index-DrmnxdVj.js +11 -0
- dropmcp/static/dist/favicon.svg +19 -0
- dropmcp/static/dist/index.html +14 -0
- dropmcp/static/icon.svg +19 -0
- dropmcp/telemetry.py +397 -0
- dropmcp/validate.py +203 -0
- dropmcp-0.1.0.dist-info/METADATA +309 -0
- dropmcp-0.1.0.dist-info/RECORD +21 -0
- dropmcp-0.1.0.dist-info/WHEEL +4 -0
- dropmcp-0.1.0.dist-info/licenses/LICENSE +201 -0
|
@@ -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
|
+
)
|
dropmcp/instructions.py
ADDED
|
@@ -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
|