@ssheleg/make-skill 0.9.1 → 0.11.0

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,275 @@
1
+ # Shipping an MCP server — mount, debug, publish
2
+
3
+ **Load this when:** the server is written and now has to reach someone —
4
+ mounting it inside an existing web app, fixing a client that cannot connect, or
5
+ publishing it to the official MCP Registry.
6
+
7
+ `references/mcp.md` is the author's model of the protocol: primitives,
8
+ lifecycle, transports, security. This file starts where that one stops. If you
9
+ are still designing tools and their schemas, Anthropic's `mcp-builder` skill
10
+ covers authoring and evaluation in depth and this file deliberately does not
11
+ repeat it — **it has nothing on the registry, on mounting, or on why a client
12
+ sees 404, which is exactly what is here.**
13
+
14
+ Registry schema URL and CLI flags move. Read them from
15
+ <https://modelcontextprotocol.io/registry/> and the
16
+ [registry repo](https://github.com/modelcontextprotocol/registry) rather than
17
+ from a copy — the pinned example below was current on **2026-08-06** and the
18
+ schema is dated by design.
19
+
20
+ ## Contents
21
+
22
+ - Mounting into an existing web app
23
+ - Auth middleware and a health endpoint
24
+ - Client configuration
25
+ - Debugging a client that will not connect
26
+ - Publishing to the MCP Registry
27
+ - Registry name formats and who may claim them
28
+ - Automating publication
29
+ - Versioning
30
+
31
+ ---
32
+
33
+ ## Mounting into an existing web app
34
+
35
+ The common production shape: you already run a FastAPI/Starlette app, and the
36
+ MCP server should live at `/mcp` on the same host. It is also where the single
37
+ most common bug lives.
38
+
39
+ **The double-path pitfall.** FastMCP defaults its internal
40
+ `streamable_http_path` to `/mcp/`. Mount that app at `/mcp` and the real
41
+ endpoint becomes `/mcp/mcp` — so every client pointed at `/mcp` gets 404, and
42
+ the SSE fallback 404s too, which reads like the server is down.
43
+
44
+ ```python
45
+ mcp = FastMCP(
46
+ "my_server",
47
+ streamable_http_path="/", # REQUIRED when mounting as a sub-app
48
+ stateless_http=True,
49
+ json_response=True,
50
+ )
51
+
52
+ app.mount("/mcp", mcp.streamable_http_app())
53
+ ```
54
+
55
+ Set `streamable_http_path="/"` whenever the app is mounted rather than served
56
+ standalone. Standalone servers keep the default.
57
+
58
+ ---
59
+
60
+ ## Auth middleware and a health endpoint
61
+
62
+ Wrap the mounted ASGI app rather than adding auth inside tool handlers — the
63
+ transport handshake happens before any tool runs, so handler-level auth leaves
64
+ the protocol surface open.
65
+
66
+ ```python
67
+ class MCPAuthMiddleware:
68
+ _PUBLIC_PATHS = {"/health"}
69
+
70
+ def __init__(self, app, *, store):
71
+ self._app, self._store = app, store
72
+
73
+ async def __call__(self, scope, receive, send):
74
+ if scope["type"] not in ("http", "websocket"):
75
+ return await self._app(scope, receive, send)
76
+ if scope.get("path", "") in self._PUBLIC_PATHS:
77
+ return await self._app(scope, receive, send)
78
+
79
+ headers = dict(scope.get("headers", []))
80
+ auth = headers.get(b"authorization", b"").decode()
81
+ if not auth.startswith("Bearer "):
82
+ return await self._send_401(send, "Missing Bearer token")
83
+ if not await self._store.validate_api_key(auth[7:]):
84
+ return await self._send_401(send, "Invalid API key")
85
+ return await self._app(scope, receive, send)
86
+
87
+ app.mount("/mcp", MCPAuthMiddleware(mcp.streamable_http_app(), store=key_store))
88
+ ```
89
+
90
+ Keep a health endpoint **outside** the mount and exempt from auth. It is what
91
+ tells you "the app is up, the MCP path is wrong" apart from "the app is down" —
92
+ the two produce identical client errors.
93
+
94
+ ```python
95
+ @app.get("/mcp/health")
96
+ async def mcp_health():
97
+ return {"status": "ok", "server": "my_server", "transport": "streamable-http"}
98
+ ```
99
+
100
+ ---
101
+
102
+ ## Client configuration
103
+
104
+ Remote (streamable HTTP):
105
+
106
+ ```json
107
+ {
108
+ "mcpServers": {
109
+ "my-server": {
110
+ "url": "https://example.com/mcp",
111
+ "headers": { "Authorization": "Bearer ${MY_SERVER_KEY}" }
112
+ }
113
+ }
114
+ }
115
+ ```
116
+
117
+ Local (stdio):
118
+
119
+ ```json
120
+ {
121
+ "mcpServers": {
122
+ "my-server": {
123
+ "command": "uv",
124
+ "args": ["--directory", "/path/to/project", "run", "python", "-m", "my_server"],
125
+ "env": { "API_KEY": "..." }
126
+ }
127
+ }
128
+ }
129
+ ```
130
+
131
+ Ship both forms in your README. A user who has to derive the config from prose
132
+ files an issue instead.
133
+
134
+ ---
135
+
136
+ ## Debugging a client that will not connect
137
+
138
+ **404 — by far the most common.** Symptom: `Error POSTing to endpoint: Not
139
+ Found`, then the SSE fallback 404s as well. Bisect the path directly:
140
+
141
+ ```bash
142
+ curl -X POST https://your-host/mcp/ -H 'Content-Type: application/json' -d '{}'
143
+ curl -X POST https://your-host/mcp -H 'Content-Type: application/json' -d '{}'
144
+ curl -X POST https://your-host/mcp/mcp -H 'Content-Type: application/json' -d '{}'
145
+ ```
146
+
147
+ If `/mcp/mcp` answers (even 401) while `/mcp/` 404s, it is the double path.
148
+ A 401 is a **success** for this test: it proves routing reached the server.
149
+
150
+ **307.** Starlette redirects `/mcp` → `/mcp/`. Most clients follow it on POST;
151
+ some do not, and the failure looks like a hang. Either document the trailing
152
+ slash or add an explicit no-slash route.
153
+
154
+ **401.** Check the Bearer prefix and the key, then check that the client is
155
+ actually sending headers — several clients drop custom headers on the SSE
156
+ fallback specifically, so the initial POST authenticates and the stream does not.
157
+
158
+ ---
159
+
160
+ ## Publishing to the MCP Registry
161
+
162
+ The registry stores **metadata pointing at your server**, not the server. You
163
+ publish a `server.json`.
164
+
165
+ Remote server:
166
+
167
+ ```json
168
+ {
169
+ "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
170
+ "name": "com.example/my-mcp",
171
+ "title": "My MCP Server",
172
+ "description": "What it does (max 100 chars).",
173
+ "version": "1.0.0",
174
+ "remotes": [
175
+ {
176
+ "type": "streamable-http",
177
+ "url": "https://example.com/mcp",
178
+ "headers": [
179
+ { "name": "Authorization", "description": "Bearer API key",
180
+ "isRequired": true, "isSecret": true }
181
+ ]
182
+ }
183
+ ]
184
+ }
185
+ ```
186
+
187
+ Local package (npm shown; `pypi` takes the same shape with `registryType`
188
+ changed):
189
+
190
+ ```json
191
+ {
192
+ "packages": [
193
+ {
194
+ "registryType": "npm",
195
+ "identifier": "@username/my-mcp-server",
196
+ "version": "1.0.0",
197
+ "transport": { "type": "stdio" }
198
+ }
199
+ ]
200
+ }
201
+ ```
202
+
203
+ PyPI additionally requires `<!-- mcp-name: your-server-name -->` in the package
204
+ README, as proof the package and the registry entry belong to the same author.
205
+
206
+ Validate before you publish — `description` is capped at **100 characters** and
207
+ a version may be published only once:
208
+
209
+ ```bash
210
+ mcp-publisher validate
211
+ mcp-publisher publish
212
+ curl "https://registry.modelcontextprotocol.io/v0.1/servers?search=my-mcp"
213
+ ```
214
+
215
+ ---
216
+
217
+ ## Registry name formats and who may claim them
218
+
219
+ The name is not free-form: it encodes how you proved ownership.
220
+
221
+ | Auth method | Name format | Example |
222
+ |---|---|---|
223
+ | GitHub | `io.github.<user>/*` | `io.github.alice/weather` |
224
+ | DNS | reverse-DNS of the domain | `com.example/my-server` |
225
+ | HTTP | reverse-DNS of the domain | `com.example/my-server` |
226
+
227
+ **GitHub** is the shortest path — `mcp-publisher login github`, OAuth device
228
+ flow, done. The name must start with your username.
229
+
230
+ **DNS** and **HTTP** claim a domain with an Ed25519 key. DNS puts the public key
231
+ in a TXT record; HTTP serves it at
232
+ `https://<domain>/.well-known/mcp-registry-auth`. Use HTTP when you control the
233
+ web root but not the zone.
234
+
235
+ ```bash
236
+ openssl genpkey -algorithm Ed25519 -out mcp-registry-key.pem
237
+ PUBLIC_KEY="$(openssl pkey -in mcp-registry-key.pem -pubout -outform DER | tail -c 32 | base64)"
238
+ # DNS: <domain>. IN TXT "v=MCPv1; k=ed25519; p=${PUBLIC_KEY}"
239
+ # HTTP: echo "v=MCPv1; k=ed25519; p=${PUBLIC_KEY}" > mcp-registry-auth
240
+ ```
241
+
242
+ Treat `mcp-registry-key.pem` as a publishing credential: it is the thing that
243
+ lets someone replace your server entry. It belongs in a secret store, not the
244
+ repository, and the CI job that uses it should be the only consumer.
245
+
246
+ ---
247
+
248
+ ## Automating publication
249
+
250
+ Same shape as the skill releases in `references/distribution.md`: a `v*` tag
251
+ triggers validate → publish. Two rules carry over unchanged.
252
+
253
+ - **Gate the publish on the validator**, in the same job. A registry entry
254
+ pointing at a broken build is worse than no entry, because clients cache it.
255
+ - **Fail on a version already published** rather than skipping quietly. A silent
256
+ skip is how a tag ends up green while the registry still serves the previous
257
+ version.
258
+
259
+ Install the CLI in CI from the released binary:
260
+
261
+ ```bash
262
+ curl -L "https://github.com/modelcontextprotocol/registry/releases/latest/download/mcp-publisher_$(uname -s | tr '[:upper:]' '[:lower:]')_$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/').tar.gz" | tar xz mcp-publisher
263
+ ```
264
+
265
+ ---
266
+
267
+ ## Versioning
268
+
269
+ The registry version is the **server's** version, and it is what clients compare
270
+ against. Keep it equal to the package version — an npm package at 1.2.0 whose
271
+ registry entry says 1.0.0 makes every bug report unactionable.
272
+
273
+ The `$schema` date and your version are unrelated: the schema is the registry's
274
+ contract and moves on the registry's schedule, so re-read it before each publish
275
+ rather than carrying the pinned URL forward on faith.
@@ -14,18 +14,33 @@ standard — including the no-argument `/make-skill` path, where a `SKILL.md`,
14
14
 
15
15
  ## Run the script first
16
16
 
17
- The mechanical half is deterministic — do not reason your way through it:
17
+ The mechanical half is deterministic — do not reason your way through it. In
18
+ Claude Code the plugin's `bin/` is on the Bash tool's PATH, so the auditor is
19
+ reachable by name:
18
20
 
19
21
  ```bash
20
- python3 "${CLAUDE_PLUGIN_ROOT}/skills/make-skill/scripts/audit_skill.py" <skill-dir> --house
22
+ make-skill-audit <skill-dir> --house
21
23
  ```
22
24
 
23
- `${CLAUDE_PLUGIN_ROOT}` is Claude Code's. On any other host the script sits at
24
- `scripts/audit_skill.py` inside the make-skill directory you are reading this
25
- from (`~/.agents/skills/make-skill/`, `~/.claude/skills/make-skill/`). No
26
- `python3` at all? Check items 1 and 9 below by hand against
27
- `references/agent-skills-spec.md` — the script implements that checklist and
28
- nothing more. Either way, the judgement items are yours.
25
+ On any other host, run the script it wraps — `scripts/audit_skill.py` inside the
26
+ make-skill directory you are reading this from (`~/.agents/skills/make-skill/`,
27
+ `~/.claude/skills/make-skill/`):
28
+
29
+ ```bash
30
+ python3 <make-skill dir>/scripts/audit_skill.py <skill-dir> --house
31
+ ```
32
+
33
+ **Do not build the command out of `${CLAUDE_PLUGIN_ROOT}`.** That variable is
34
+ substituted into skill, command and agent *text*, but it is **not exported to
35
+ the Bash tool's environment** — a command written around it expands to
36
+ `/skills/...` and dies with "No such file or directory" (measured, Claude Code
37
+ 2.1.220). That failure is how a mechanical check turns into a reasoned-about
38
+ PASS, which is the one outcome this file exists to prevent.
39
+
40
+ No `python3` at all? Record items 1 and 9 as **NOT-RUN** with that reason and
41
+ check them by hand against `references/agent-skills-spec.md` — the script
42
+ implements that checklist and nothing more. Either way, the judgement items are
43
+ yours.
29
44
 
30
45
  ## How to report
31
46
 
@@ -35,11 +50,21 @@ Audit first, fix second, in the same session. One verdict per item:
35
50
  |---|---|
36
51
  | **PASS** | the check was actually run — a command's output or a `file:line` |
37
52
  | **GAP** | what is wrong, the evidence, and the fix that closes it |
53
+ | **NOT-RUN** | the tool the check needs is absent — name the tool and the reason |
38
54
 
39
55
  "Looks fine" is not a verdict. Neither is a PASS on a check that was reasoned
40
56
  about rather than executed: `python3 test/validate.py`, `claude plugin validate
41
57
  … --strict`, `npx skills add <repo> --list` all produce output, and the output is
42
- the evidence. Report the gap table before changing anything, then fix.
58
+ the evidence.
59
+
60
+ **NOT-RUN exists so that "I could not run this" has somewhere to go.** A
61
+ checklist with only PASS and GAP forces an unrunnable check into one of them,
62
+ and the cheaper lie is PASS. Item 1's `skills-ref validate` is the standing
63
+ example: it installs from source only, so on most machines it is NOT-RUN, and
64
+ saying so is the honest result. A NOT-RUN you did not try to resolve is still a
65
+ gap in the audit — say what would make it runnable.
66
+
67
+ Report the table before changing anything, then fix.
43
68
 
44
69
  ## The 14-item audit checklist
45
70
 
@@ -84,15 +109,17 @@ the evidence. Report the gap table before changing anything, then fix.
84
109
  skills add <repo> --list` lists ONLY real skills; `npx <name>` works from a
85
110
  non-repo cwd if it is published; `.mdc` rules valid and free of relative
86
111
  links.
87
- 11. **Repo meta**: homepage, description and topics set on the forge; LICENSE
112
+ 11. **Repo meta**: homepage, description and topics set on the forge (any
113
+ consistent homepage convention — do not "fix" a deliberate one); LICENSE
88
114
  present and declared in the front matter and the marketplace entry;
89
115
  CHANGELOG current; public repos also carry CONTRIBUTING.md and SECURITY.md.
90
116
  12. **Gotcha compliance**: the list in `SKILL.md`, plus the installer traps in
91
117
  `references/distribution.md` when the repo ships a CLI or a validator.
92
118
  13. **Protocol dependencies** if it touches MCP or A2A (`references/mcp.md`,
93
- `references/a2a.md`): dependency and protocol version in `compatibility`,
94
- discovery instead of hardcoded tool names, the untrusted-output rule stated,
95
- interactive auth handled as a human step rather than a retry loop.
119
+ `references/mcp-ship.md` once a server is being shipped, `references/a2a.md`):
120
+ dependency and protocol version in `compatibility`, discovery instead of
121
+ hardcoded tool names, the untrusted-output rule stated, interactive auth
122
+ handled as a human step rather than a retry loop.
96
123
  14. **Host capabilities and their fallbacks**
97
124
  (`references/host-capabilities.md`) if it ships a hook, subagent, command or
98
125
  MCP server: the degradation contract written in the body for all three axes
@@ -171,7 +171,10 @@ def _check_name(a, fm, lines, dir_name, rel):
171
171
  a.gap("NAME_CHARSET", "name %r must be lowercase a-z0-9 with single internal "
172
172
  "hyphens — no uppercase, no leading/trailing or doubled hyphen" % name, rel, ln)
173
173
  else:
174
- a.ok("NAME_CHARSET", "name %r is spec-legal" % name, rel, ln)
174
+ # Charset only. Saying "spec-legal" here would contradict NAME_RESERVED
175
+ # and NAME_XML below, and a PASS line quoted out of context is exactly
176
+ # how a wrong verdict acquires real command output as its evidence.
177
+ a.ok("NAME_CHARSET", "name %r uses a legal charset" % name, rel, ln)
175
178
  if name != dir_name:
176
179
  a.gap("NAME_DIR", "name %r != directory %r — the spec requires them to match, "
177
180
  "and the Skills API matches the uploaded top-level directory against it"
@@ -211,10 +214,16 @@ def _check_description(a, fm, lines, rel, house):
211
214
  else:
212
215
  a.ok("DESC_PERSON", "description is third person", rel, ln)
213
216
  if house:
217
+ # Both report either way: "the house rules were checked" has to be
218
+ # provable from the output the canon tells the agent to cite.
214
219
  if not desc.lower().startswith("use when"):
215
220
  a.gap("DESC_USEWHEN", "description must start with 'Use when …' (house rule)", rel, ln)
221
+ else:
222
+ a.ok("DESC_USEWHEN", "description opens with 'Use when …' (house rule)", rel, ln)
216
223
  if not re.search(r"[а-яё]", desc, re.I):
217
224
  a.gap("DESC_RU", "description carries no Russian trigger phrases (house rule)", rel, ln)
225
+ else:
226
+ a.ok("DESC_RU", "description carries Russian trigger phrases (house rule)", rel, ln)
218
227
 
219
228
 
220
229
  def _check_optional_fields(a, fm, lines, rel):
@@ -253,13 +262,18 @@ def _check_keys(a, fm, lines, rel):
253
262
  def _check_body_budget(a, body, rel):
254
263
  n_lines = body.count("\n") + 1
255
264
  est = int(len(body) / CHARS_PER_TOKEN)
265
+ # Both, not either: a body over the line budget still has to report its
266
+ # token count, or the second fix arrives only after the first one ships.
267
+ over = False
256
268
  if n_lines >= BODY_MAX_LINES:
257
269
  a.gap("BODY_LINES", "body is %d lines, the budget is < %d — move detail into "
258
270
  "references/" % (n_lines, BODY_MAX_LINES), rel)
259
- elif est >= BODY_MAX_TOKENS:
271
+ over = True
272
+ if est >= BODY_MAX_TOKENS:
260
273
  a.gap("BODY_TOKENS", "body is ~%d tokens (%d chars / %s), the budget is < %d"
261
274
  % (est, len(body), CHARS_PER_TOKEN, BODY_MAX_TOKENS), rel)
262
- else:
275
+ over = True
276
+ if not over:
263
277
  a.ok("BODY_BUDGET", "body is %d lines / ~%d tokens (budget %d / %d)"
264
278
  % (n_lines, est, BODY_MAX_LINES, BODY_MAX_TOKENS), rel)
265
279
 
@@ -295,21 +309,27 @@ def _check_bundle(a, skill_dir, skill_text, dir_name):
295
309
 
296
310
 
297
311
  def _check_links(a, skill_dir, skill_text, rel):
298
- """A relative link that escapes the skill directory arrives broken everywhere."""
299
- escaped = broken = False
300
- for target in LINK_RE.findall(skill_text):
301
- if target.startswith(("http://", "https://", "mailto:", "#")):
302
- continue
303
- if target.startswith("../") or "/../" in target:
304
- a.gap("LINK_ESCAPE", "link %r escapes the skill directory — packagers ship "
305
- "that directory alone, so it arrives broken on every agent" % target, rel)
306
- escaped = True
307
- continue
308
- path = os.path.normpath(os.path.join(skill_dir, target.split("#")[0]))
309
- if not os.path.exists(path):
310
- a.gap("LINK_BROKEN", "link %r does not resolve" % target, rel)
311
- broken = True
312
- if not escaped and not broken:
312
+ """A relative link that escapes the skill directory arrives broken everywhere.
313
+
314
+ Reported per line: the canon defines evidence as a `file:line`, so a finding
315
+ without one forces the agent to either drop the evidence or invent it.
316
+ """
317
+ bad = False
318
+ for i, line in enumerate(skill_text.split("\n"), start=1):
319
+ for target in LINK_RE.findall(line):
320
+ if target.startswith(("http://", "https://", "mailto:", "#")):
321
+ continue
322
+ if target.startswith("../") or "/../" in target:
323
+ a.gap("LINK_ESCAPE", "link %r escapes the skill directory — packagers "
324
+ "ship that directory alone, so it arrives broken on every agent"
325
+ % target, rel, i)
326
+ bad = True
327
+ continue
328
+ path = os.path.normpath(os.path.join(skill_dir, target.split("#")[0]))
329
+ if not os.path.exists(path):
330
+ a.gap("LINK_BROKEN", "link %r does not resolve" % target, rel, i)
331
+ bad = True
332
+ if not bad:
313
333
  a.ok("LINK_INTEGRITY", "every relative link resolves and stays inside the skill", rel)
314
334
 
315
335
 
@@ -335,8 +355,11 @@ def _check_prose(a, body, rel):
335
355
  a.gap("TIME_BRANCH", "time-branching instruction (%r) — it is wrong the day "
336
356
  "it ships; put superseded material under '## Old patterns'"
337
357
  % raw.strip()[:60], rel, i)
338
- # a pointer at a directory rather than a file with a load condition
339
- for i, line in enumerate(body.split("\n"), start=1):
358
+ # A pointer at a directory rather than a file with a load condition. Quoted
359
+ # spans are blanked here too: a canon that forbids "see references/" has to
360
+ # be able to quote the phrase it forbids.
361
+ for i, raw in enumerate(body.split("\n"), start=1):
362
+ line = _strip_quoted(raw)
340
363
  if re.search(r"\b(?:see|read)\s+`?(?:references|scripts|assets)/`?(?![\w.-])", line, re.I):
341
364
  a.gap("REF_NO_TRIGGER", "points at a directory instead of a file with a "
342
365
  "stated load condition — 'read X when Y' beats 'see references/'", rel, i)