Skip to content

Commit 5cb6287

Browse files
TMHSDigitalclaude
andcommitted
fix(catalog): complete search index and correct registry entries
Search index (#101): aggregate_search.py gains --clone, which shallow clones every registry repo so the index can be rebuilt without a local fleet checkout, and a source-scan fallback that reads tool names from @mcp.tool() / server.tool() / registerTool() calls when a repo ships no mcp-tools.json. The rebuilt index has names for all 17 tools (was 3) and uses the same script-safe serialization as the sync script. Tests assert every tool with content contributes searchable names. Registry (#112): cfx-mcp now lists its 3 tools, its live Pages homepage, and drops the unpublished @tmhsdigital/cfx-mcp npm name. Blender lists its live homepage and the 18 skills and 9 rules its plugin.json ships. Schema validation moves from inline YAML to scripts/validate_registry.py (tested) and adds unique slug/repo, @TMHS npm scope, homepage required when Pages are published, license allowlist, and non-negative counts. An experimental mcp-server may omit npm until it is published. Closes #101 Closes #112 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com>
1 parent 6653b5b commit 5cb6287

10 files changed

Lines changed: 427 additions & 90 deletions

File tree

‎.github/workflows/validate.yml‎

Lines changed: 1 addition & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -20,75 +20,7 @@ jobs:
2020
run: python3 -c "import json; json.load(open('registry.json'))"
2121

2222
- name: Validate registry schema
23-
shell: bash
24-
run: |
25-
python3 << 'PYEOF'
26-
import json, sys
27-
data = json.load(open("registry.json"))
28-
assert isinstance(data, list), "registry.json must be an array"
29-
SCHEMA = {
30-
"name": str,
31-
"repo": str,
32-
"slug": str,
33-
"description": str,
34-
"type": str,
35-
"homepage": str,
36-
"skills": int,
37-
"rules": int,
38-
"mcpTools": int,
39-
"extras": dict,
40-
"topics": list,
41-
"status": str,
42-
"language": str,
43-
"license": str,
44-
"pagesType": str,
45-
"hasCI": bool,
46-
}
47-
OPTIONAL = {"npm": str, "archetype": str}
48-
VALID_TYPES = {"cursor-plugin", "mcp-server"}
49-
VALID_STATUS = {"experimental", "beta", "active", "maintenance", "deprecated", "archived"}
50-
VALID_PAGES = {"static", "mkdocs", "none"}
51-
VALID_ARCHETYPES = {"deployed-service"}
52-
errors = []
53-
for i, tool in enumerate(data):
54-
label = "Entry %d (%s)" % (i, tool.get("name", "?"))
55-
for field, expected_type in SCHEMA.items():
56-
if field not in tool:
57-
errors.append("%s: missing required field '%s'" % (label, field))
58-
elif not isinstance(tool[field], expected_type):
59-
errors.append("%s: '%s' must be %s, got %s" % (label, field, expected_type.__name__, type(tool[field]).__name__))
60-
for field, expected_type in OPTIONAL.items():
61-
if field in tool and not isinstance(tool[field], expected_type):
62-
errors.append("%s: optional '%s' must be %s" % (label, field, expected_type.__name__))
63-
if tool.get("type") not in VALID_TYPES:
64-
errors.append("%s: type must be one of %s" % (label, VALID_TYPES))
65-
if tool.get("status") not in VALID_STATUS:
66-
errors.append("%s: status must be one of %s" % (label, VALID_STATUS))
67-
if tool.get("pagesType") not in VALID_PAGES:
68-
errors.append("%s: pagesType must be one of %s" % (label, VALID_PAGES))
69-
if not isinstance(tool.get("topics", []), list):
70-
pass
71-
elif not all(isinstance(t, str) for t in tool.get("topics", [])):
72-
errors.append("%s: topics must be array of strings" % label)
73-
archetype = tool.get("archetype")
74-
if "archetype" in tool:
75-
if archetype not in VALID_ARCHETYPES:
76-
errors.append("%s: archetype must be one of %s" % (label, VALID_ARCHETYPES))
77-
if tool.get("type") != "mcp-server":
78-
errors.append("%s: archetype is only valid for mcp-server entries" % label)
79-
if tool.get("type") == "mcp-server":
80-
npm = tool.get("npm")
81-
if archetype == "deployed-service":
82-
if "npm" in tool:
83-
errors.append("%s: deployed-service must omit npm" % label)
84-
elif not (isinstance(npm, str) and npm.strip()):
85-
errors.append("%s: mcp-server entries must have a non-empty npm field" % label)
86-
if errors:
87-
for e in errors:
88-
print("::error::" + e, file=sys.stderr)
89-
sys.exit(1)
90-
print("Registry valid: %d tools" % len(data))
91-
PYEOF
23+
run: python3 scripts/validate_registry.py
9224

9325
validate-docs:
9426
name: Validate docs site

‎CLAUDE.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -86,8 +86,8 @@ Static HTML/CSS/JS. No build step. No external CDN. The `pages.yml` workflow cop
8686
| Steam Cursor Plugin | Plugin | 30 | 9 | 26 |
8787
| Steam MCP Server | MCP Server | 0 | 0 | 26 |
8888
| Developer Tools MCP | MCP Server | 0 | 0 | 7 |
89-
| CFX MCP | MCP Server | 0 | 0 | 0 |
90-
| Blender Developer Tools | Plugin | 12 | 6 | 0 |
89+
| CFX MCP | MCP Server | 0 | 0 | 3 |
90+
| Blender Developer Tools | Plugin | 18 | 9 | 0 |
9191
| Local AI MCP | MCP Server | 0 | 0 | 16 |
9292
| Screencast MCP | MCP Server | 0 | 0 | 25 |
9393
| Tailscale MCP | MCP Server | 0 | 0 | 6 |
@@ -96,7 +96,7 @@ Static HTML/CSS/JS. No build step. No external CDN. The `pages.yml` workflow cop
9696
<!-- registry:tools:end -->
9797

9898
<!-- registry:stats:start -->
99-
**Totals:** 189 skills, 77 rules, 438 MCP tools across 17 repos
99+
**Totals:** 195 skills, 80 rules, 441 MCP tools across 17 repos
100100
<!-- registry:stats:end -->
101101

102102
## Development Workflow

‎README.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@
3131

3232
<!-- registry:stats:start -->
3333
<p align="center">
34-
17 repos &nbsp;&bull;&nbsp; 189 skills &nbsp;&bull;&nbsp; 77 rules &nbsp;&bull;&nbsp; 438 MCP tools
34+
17 repos &nbsp;&bull;&nbsp; 195 skills &nbsp;&bull;&nbsp; 80 rules &nbsp;&bull;&nbsp; 441 MCP tools
3535
</p>
3636
<!-- registry:stats:end -->
3737

@@ -63,8 +63,8 @@ flowchart LR
6363
| **Steam Cursor Plugin** | Plugin | 30 | 9 | 26 | [![Repo](https://img.shields.io/badge/repo-blue?logo=github)](https://github.com/TMHSDigital/Steam-Cursor-Plugin) [![Docs](https://img.shields.io/badge/docs-7c3aed)](https://tmhsdigital.github.io/Steam-Cursor-Plugin/) |
6464
| **Steam MCP Server** | MCP Server | - | - | 26 | [![Repo](https://img.shields.io/badge/repo-blue?logo=github)](https://github.com/TMHSDigital/steam-mcp) [![npm](https://img.shields.io/badge/npm-cb3837?logo=npm&logoColor=white)](https://www.npmjs.com/package/@tmhs/steam-mcp) |
6565
| **Developer Tools MCP** | MCP Server | - | - | 7 | [![Repo](https://img.shields.io/badge/repo-blue?logo=github)](https://github.com/TMHSDigital/Developer-Tools-MCP) [![Docs](https://img.shields.io/badge/docs-7c3aed)](https://tmhsdigital.github.io/Developer-Tools-MCP/) [![npm](https://img.shields.io/badge/npm-cb3837?logo=npm&logoColor=white)](https://www.npmjs.com/package/@tmhs/devtools-mcp) |
66-
| **CFX MCP** | MCP Server | - | - | 0 | [![Repo](https://img.shields.io/badge/repo-blue?logo=github)](https://github.com/TMHSDigital/cfx-mcp) [![npm](https://img.shields.io/badge/npm-cb3837?logo=npm&logoColor=white)](https://www.npmjs.com/package/@tmhsdigital/cfx-mcp) |
67-
| **Blender Developer Tools** | Plugin | 12 | 6 | 0 | [![Repo](https://img.shields.io/badge/repo-blue?logo=github)](https://github.com/TMHSDigital/Blender-Developer-Tools) |
66+
| **CFX MCP** | MCP Server | - | - | 3 | [![Repo](https://img.shields.io/badge/repo-blue?logo=github)](https://github.com/TMHSDigital/cfx-mcp) [![Docs](https://img.shields.io/badge/docs-7c3aed)](https://tmhsdigital.github.io/cfx-mcp/) |
67+
| **Blender Developer Tools** | Plugin | 18 | 9 | 0 | [![Repo](https://img.shields.io/badge/repo-blue?logo=github)](https://github.com/TMHSDigital/Blender-Developer-Tools) [![Docs](https://img.shields.io/badge/docs-7c3aed)](https://tmhsdigital.github.io/Blender-Developer-Tools/) |
6868
| **Local AI MCP** | MCP Server | - | - | 16 | [![Repo](https://img.shields.io/badge/repo-blue?logo=github)](https://github.com/TMHSDigital/local-ai-mcp) [![Docs](https://img.shields.io/badge/docs-7c3aed)](https://tmhsdigital.github.io/local-ai-mcp/) [![npm](https://img.shields.io/badge/npm-cb3837?logo=npm&logoColor=white)](https://www.npmjs.com/package/@tmhs/local-ai-mcp) |
6969
| **Screencast MCP** | MCP Server | - | - | 25 | [![Repo](https://img.shields.io/badge/repo-blue?logo=github)](https://github.com/TMHSDigital/screencast-mcp) [![Docs](https://img.shields.io/badge/docs-7c3aed)](https://tmhsdigital.github.io/screencast-mcp/) [![npm](https://img.shields.io/badge/npm-cb3837?logo=npm&logoColor=white)](https://www.npmjs.com/package/@tmhs/screencast-mcp) |
7070
| **Tailscale MCP** | MCP Server | - | - | 6 | [![Repo](https://img.shields.io/badge/repo-blue?logo=github)](https://github.com/TMHSDigital/tailscale-mcp) [![Docs](https://img.shields.io/badge/docs-7c3aed)](https://tmhsdigital.github.io/tailscale-mcp/) [![npm](https://img.shields.io/badge/npm-cb3837?logo=npm&logoColor=white)](https://www.npmjs.com/package/@tmhs/tailscale-mcp) |

‎docs/index.html‎

Lines changed: 2 additions & 2 deletions
Large diffs are not rendered by default.

‎docs/search-index.json‎

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

‎registry.json‎

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -258,28 +258,27 @@
258258
"slug": "cfx-mcp",
259259
"description": "MCP server providing agent-callable tools for CFX/FiveM resource development, server management, and community platform integration.",
260260
"type": "mcp-server",
261-
"homepage": "",
261+
"homepage": "https://tmhsdigital.github.io/cfx-mcp/",
262262
"skills": 0,
263263
"rules": 0,
264-
"mcpTools": 0,
264+
"mcpTools": 3,
265265
"extras": {},
266266
"topics": [],
267267
"status": "experimental",
268268
"language": "TypeScript",
269269
"license": "CC-BY-NC-ND-4.0",
270-
"pagesType": "none",
271-
"hasCI": true,
272-
"npm": "@tmhsdigital/cfx-mcp"
270+
"pagesType": "static",
271+
"hasCI": true
273272
},
274273
{
275274
"name": "Blender Developer Tools",
276275
"repo": "TMHSDigital/Blender-Developer-Tools",
277276
"slug": "blender-developer-tools",
278277
"description": "Cursor and Claude Code skills, rules, snippets, and templates for Blender Python add-on and scripting development",
279278
"type": "cursor-plugin",
280-
"homepage": "",
281-
"skills": 12,
282-
"rules": 6,
279+
"homepage": "https://tmhsdigital.github.io/Blender-Developer-Tools/",
280+
"skills": 18,
281+
"rules": 9,
283282
"mcpTools": 0,
284283
"extras": {
285284
"snippets": 17,
@@ -295,7 +294,7 @@
295294
"status": "active",
296295
"language": "Python",
297296
"license": "CC-BY-NC-ND-4.0",
298-
"pagesType": "none",
297+
"pagesType": "static",
299298
"hasCI": true
300299
},
301300
{

‎scripts/validate_registry.py‎

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
#!/usr/bin/env python3
2+
"""Schema and consistency validation for registry.json.
3+
4+
Runs in CI (validate.yml) and is importable by tests. Exits 1 and prints
5+
GitHub ``::error::`` annotations when any entry is invalid.
6+
7+
Usage: python scripts/validate_registry.py [path/to/registry.json]
8+
"""
9+
from __future__ import annotations
10+
11+
import json
12+
import sys
13+
from pathlib import Path
14+
from typing import Any
15+
16+
REPO_ROOT = Path(__file__).resolve().parent.parent
17+
18+
SCHEMA: dict[str, type] = {
19+
"name": str,
20+
"repo": str,
21+
"slug": str,
22+
"description": str,
23+
"type": str,
24+
"homepage": str,
25+
"skills": int,
26+
"rules": int,
27+
"mcpTools": int,
28+
"extras": dict,
29+
"topics": list,
30+
"status": str,
31+
"language": str,
32+
"license": str,
33+
"pagesType": str,
34+
"hasCI": bool,
35+
}
36+
OPTIONAL: dict[str, type] = {"npm": str, "archetype": str, "category": str}
37+
VALID_TYPES = {"cursor-plugin", "mcp-server"}
38+
VALID_STATUS = {"experimental", "beta", "active", "maintenance", "deprecated", "archived"}
39+
VALID_PAGES = {"static", "mkdocs", "none"}
40+
VALID_ARCHETYPES = {"deployed-service"}
41+
VALID_LICENSES = {"CC-BY-NC-ND-4.0", "CC-BY-NC-ND-4.0 AND MIT", "MIT", "Apache-2.0"}
42+
NPM_SCOPE = "@tmhs/"
43+
COUNT_FIELDS = ("skills", "rules", "mcpTools")
44+
45+
46+
def validate(data: Any) -> list[str]:
47+
"""Return a list of human-readable errors (empty when valid)."""
48+
if not isinstance(data, list):
49+
return ["registry.json must be an array"]
50+
51+
errors: list[str] = []
52+
seen_slugs: dict[str, str] = {}
53+
seen_repos: dict[str, str] = {}
54+
55+
for i, tool in enumerate(data):
56+
label = "Entry %d (%s)" % (i, tool.get("name", "?") if isinstance(tool, dict) else "?")
57+
if not isinstance(tool, dict):
58+
errors.append("%s: must be an object" % label)
59+
continue
60+
61+
for field, expected in SCHEMA.items():
62+
if field not in tool:
63+
errors.append("%s: missing required field '%s'" % (label, field))
64+
elif not isinstance(tool[field], expected) or (
65+
expected is int and isinstance(tool[field], bool)
66+
):
67+
errors.append(
68+
"%s: '%s' must be %s, got %s"
69+
% (label, field, expected.__name__, type(tool[field]).__name__)
70+
)
71+
for field, expected in OPTIONAL.items():
72+
if field in tool and not isinstance(tool[field], expected):
73+
errors.append("%s: optional '%s' must be %s" % (label, field, expected.__name__))
74+
75+
for field in COUNT_FIELDS:
76+
value = tool.get(field)
77+
if isinstance(value, int) and not isinstance(value, bool) and value < 0:
78+
errors.append("%s: '%s' must be >= 0" % (label, field))
79+
80+
if tool.get("type") not in VALID_TYPES:
81+
errors.append("%s: type must be one of %s" % (label, sorted(VALID_TYPES)))
82+
if tool.get("status") not in VALID_STATUS:
83+
errors.append("%s: status must be one of %s" % (label, sorted(VALID_STATUS)))
84+
if tool.get("pagesType") not in VALID_PAGES:
85+
errors.append("%s: pagesType must be one of %s" % (label, sorted(VALID_PAGES)))
86+
if tool.get("license") not in VALID_LICENSES:
87+
errors.append("%s: license must be one of %s" % (label, sorted(VALID_LICENSES)))
88+
89+
topics = tool.get("topics", [])
90+
if isinstance(topics, list) and not all(isinstance(t, str) for t in topics):
91+
errors.append("%s: topics must be array of strings" % label)
92+
93+
slug = tool.get("slug")
94+
if isinstance(slug, str):
95+
if slug in seen_slugs:
96+
errors.append("%s: duplicate slug '%s' (also %s)" % (label, slug, seen_slugs[slug]))
97+
seen_slugs[slug] = label
98+
repo = tool.get("repo")
99+
if isinstance(repo, str):
100+
key = repo.lower()
101+
if key in seen_repos:
102+
errors.append("%s: duplicate repo '%s' (also %s)" % (label, repo, seen_repos[key]))
103+
seen_repos[key] = label
104+
if repo.count("/") != 1:
105+
errors.append("%s: repo must be 'owner/name'" % label)
106+
107+
homepage = tool.get("homepage")
108+
if tool.get("pagesType") in ("static", "mkdocs") and not homepage:
109+
errors.append("%s: homepage is required when pagesType is not 'none'" % label)
110+
if homepage and not str(homepage).startswith("https://"):
111+
errors.append("%s: homepage must be an https URL" % label)
112+
113+
npm = tool.get("npm")
114+
if "npm" in tool and isinstance(npm, str) and not npm.startswith(NPM_SCOPE):
115+
errors.append("%s: npm package must use the %s scope" % (label, NPM_SCOPE))
116+
117+
archetype = tool.get("archetype")
118+
if "archetype" in tool:
119+
if archetype not in VALID_ARCHETYPES:
120+
errors.append("%s: archetype must be one of %s" % (label, sorted(VALID_ARCHETYPES)))
121+
if tool.get("type") != "mcp-server":
122+
errors.append("%s: archetype is only valid for mcp-server entries" % label)
123+
if tool.get("type") == "mcp-server":
124+
if archetype == "deployed-service":
125+
if "npm" in tool:
126+
errors.append("%s: deployed-service must omit npm" % label)
127+
elif not (isinstance(npm, str) and npm.strip()):
128+
# An experimental server may not be published yet; every
129+
# other npm-archetype server must name its package.
130+
if tool.get("status") != "experimental":
131+
errors.append("%s: mcp-server entries must have a non-empty npm field" % label)
132+
133+
return errors
134+
135+
136+
def main(argv: list[str]) -> int:
137+
path = Path(argv[1]) if len(argv) > 1 else REPO_ROOT / "registry.json"
138+
try:
139+
data = json.loads(path.read_text(encoding="utf-8"))
140+
except (OSError, ValueError) as exc:
141+
print("::error::cannot read %s: %s" % (path, exc), file=sys.stderr)
142+
return 1
143+
errors = validate(data)
144+
if errors:
145+
for e in errors:
146+
print("::error::" + e, file=sys.stderr)
147+
return 1
148+
print("Registry valid: %d tools" % len(data))
149+
return 0
150+
151+
152+
if __name__ == "__main__":
153+
sys.exit(main(sys.argv))

0 commit comments

Comments
 (0)