boost-skill-cli 1.0.51__tar.gz → 1.0.52__tar.gz

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.
Files changed (113) hide show
  1. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/PKG-INFO +1 -1
  2. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/boost_cli/_version.py +2 -2
  3. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/boost_cli/commands/configuration.py +158 -96
  4. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/boost_cli/commands/discovery.py +42 -0
  5. boost_skill_cli-1.0.52/boost_cli/core/mcp.py +70 -0
  6. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/boost_skill_cli.egg-info/PKG-INFO +1 -1
  7. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/boost_skill_cli.egg-info/SOURCES.txt +2 -0
  8. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/docs/rag-architecture.md +33 -15
  9. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/tests/functional/test_cli_configuration.py +54 -1
  10. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/tests/functional/test_cli_discovery.py +95 -0
  11. boost_skill_cli-1.0.52/tests/unit/test_mcp.py +83 -0
  12. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/.gitignore +0 -0
  13. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/CLAUDE.md +0 -0
  14. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/CONTRIBUTING.md +0 -0
  15. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/LICENSE +0 -0
  16. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/MANIFEST.in +0 -0
  17. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/Makefile +0 -0
  18. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/README.md +0 -0
  19. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/SECURITY.md +0 -0
  20. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/boost +0 -0
  21. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/boost_cli/__init__.py +0 -0
  22. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/boost_cli/__main__.py +0 -0
  23. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/boost_cli/cli.py +0 -0
  24. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/boost_cli/cliparse.py +0 -0
  25. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/boost_cli/commands/__init__.py +0 -0
  26. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/boost_cli/commands/bmad.py +0 -0
  27. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/boost_cli/commands/hooks.py +0 -0
  28. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/boost_cli/commands/info.py +0 -0
  29. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/boost_cli/commands/intelligence.py +0 -0
  30. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/boost_cli/commands/pkg.py +0 -0
  31. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/boost_cli/commands/quality.py +0 -0
  32. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/boost_cli/commands/taps.py +0 -0
  33. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/boost_cli/commands/team.py +0 -0
  34. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/boost_cli/core/__init__.py +0 -0
  35. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/boost_cli/core/agents.py +0 -0
  36. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/boost_cli/core/ai.py +0 -0
  37. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/boost_cli/core/catalog.py +0 -0
  38. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/boost_cli/core/claude_settings.py +0 -0
  39. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/boost_cli/core/config.py +0 -0
  40. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/boost_cli/core/dense.py +0 -0
  41. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/boost_cli/core/embed.py +0 -0
  42. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/boost_cli/core/frontmatter.py +0 -0
  43. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/boost_cli/core/gitutil.py +0 -0
  44. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/boost_cli/core/journal.py +0 -0
  45. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/boost_cli/core/lockfile.py +0 -0
  46. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/boost_cli/core/logs.py +0 -0
  47. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/boost_cli/core/output.py +0 -0
  48. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/boost_cli/core/paths.py +0 -0
  49. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/boost_cli/core/policy.py +0 -0
  50. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/boost_cli/core/rag.py +0 -0
  51. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/boost_cli/core/registry.py +0 -0
  52. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/boost_cli/core/store.py +0 -0
  53. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/boost_cli/core/util.py +0 -0
  54. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/boost_cli/data/registries.json +0 -0
  55. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/boost_cli/errors.py +0 -0
  56. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/boost_cli/spin.py +0 -0
  57. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/boost_skill_cli.egg-info/dependency_links.txt +0 -0
  58. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/boost_skill_cli.egg-info/entry_points.txt +0 -0
  59. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/boost_skill_cli.egg-info/requires.txt +0 -0
  60. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/boost_skill_cli.egg-info/top_level.txt +0 -0
  61. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/docs/.claude/settings.local.json +0 -0
  62. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/docs/DEBUGGING.md +0 -0
  63. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/docs/demo.gif +0 -0
  64. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/docs/demo.tape +0 -0
  65. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/docs/design-roadmap.html +0 -0
  66. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/docs/index.html +0 -0
  67. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/docs/roadmap.html +0 -0
  68. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/index.html +0 -0
  69. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/pyproject.toml +0 -0
  70. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/scripts/build_registries.py +0 -0
  71. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/scripts/mutation_gate.py +0 -0
  72. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/setup.cfg +0 -0
  73. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/style/README.md +0 -0
  74. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/style/boost.css +0 -0
  75. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/style/boost.js +0 -0
  76. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/style/demo.html +0 -0
  77. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/tests/conftest.py +0 -0
  78. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/tests/functional/test_cli_bmad.py +0 -0
  79. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/tests/functional/test_cli_hooks.py +0 -0
  80. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/tests/functional/test_cli_info.py +0 -0
  81. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/tests/functional/test_cli_intelligence.py +0 -0
  82. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/tests/functional/test_cli_pkg.py +0 -0
  83. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/tests/functional/test_cli_quality.py +0 -0
  84. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/tests/functional/test_cli_taps.py +0 -0
  85. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/tests/functional/test_cli_team.py +0 -0
  86. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/tests/functional/test_everyday_loop.py +0 -0
  87. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/tests/make_fixture.py +0 -0
  88. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/tests/smoke.sh +0 -0
  89. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/tests/unit/test_agents.py +0 -0
  90. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/tests/unit/test_ai.py +0 -0
  91. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/tests/unit/test_catalog.py +0 -0
  92. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/tests/unit/test_claude_settings.py +0 -0
  93. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/tests/unit/test_cliparse.py +0 -0
  94. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/tests/unit/test_config.py +0 -0
  95. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/tests/unit/test_dense.py +0 -0
  96. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/tests/unit/test_embed.py +0 -0
  97. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/tests/unit/test_errors_and_cli_table.py +0 -0
  98. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/tests/unit/test_frontmatter.py +0 -0
  99. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/tests/unit/test_gitutil.py +0 -0
  100. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/tests/unit/test_journal.py +0 -0
  101. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/tests/unit/test_lockfile.py +0 -0
  102. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/tests/unit/test_logs.py +0 -0
  103. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/tests/unit/test_mutation_hardening.py +0 -0
  104. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/tests/unit/test_output.py +0 -0
  105. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/tests/unit/test_paths.py +0 -0
  106. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/tests/unit/test_policy.py +0 -0
  107. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/tests/unit/test_rag.py +0 -0
  108. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/tests/unit/test_registry.py +0 -0
  109. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/tests/unit/test_spin.py +0 -0
  110. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/tests/unit/test_store.py +0 -0
  111. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/tests/unit/test_token_parity.py +0 -0
  112. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/tests/unit/test_util.py +0 -0
  113. {boost_skill_cli-1.0.51 → boost_skill_cli-1.0.52}/tests/unit/test_version.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: boost-skill-cli
3
- Version: 1.0.51
3
+ Version: 1.0.52
4
4
  Summary: boost — Homebrew for AI coding skills
5
5
  Author: Jonathan Reyes
6
6
  License: GNU GENERAL PUBLIC LICENSE
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '1.0.51'
22
- __version_tuple__ = version_tuple = (1, 0, 51)
21
+ __version__ = version = '1.0.52'
22
+ __version_tuple__ = version_tuple = (1, 0, 52)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -20,7 +20,7 @@ from pathlib import Path
20
20
  from .. import cliparse
21
21
  from .. import __version__
22
22
  from ..core import agents, catalog, config, frontmatter, gitutil, journal
23
- from ..core import lockfile, paths, policy, rag, registry, store, util
23
+ from ..core import lockfile, mcp, paths, policy, rag, registry, store, util
24
24
  from ..core import output as out
25
25
  from ..errors import BoostError
26
26
 
@@ -864,105 +864,167 @@ def cmd_serve(argv) -> int:
864
864
 
865
865
  # ---------------------------------------------------------------- mcp
866
866
 
867
- _MCP_TOOLS = [
868
- {"name": "boost_search",
869
- "description": "Search AI coding skills across the configured tap registries",
870
- "inputSchema": {"type": "object",
871
- "properties": {"query": {"type": "string",
872
- "description": "search terms"}},
873
- "required": ["query"]}},
874
- {"name": "boost_list",
875
- "description": "List the skills currently installed by boost",
876
- "inputSchema": {"type": "object", "properties": {}}},
877
- {"name": "boost_info",
878
- "description": "Show detailed information about one skill",
879
- "inputSchema": {"type": "object",
880
- "properties": {"name": {"type": "string",
881
- "description": "skill name"}},
882
- "required": ["name"]}},
883
- {"name": "boost_install",
884
- "description": "Install a skill from a configured tap registry",
885
- "inputSchema": {"type": "object",
886
- "properties": {"name": {"type": "string",
887
- "description": "skill name"}},
888
- "required": ["name"]}},
889
- {"name": "boost_doctor",
890
- "description": "Health summary of the boost skill environment",
891
- "inputSchema": {"type": "object", "properties": {}}},
892
- ]
867
+ # The MCP tool surface is an extensible registry (core/mcp.py): each handler is
868
+ # a small `fn(args) -> (text, is_error)` that self-registers its JSON spec, so a
869
+ # new capability is one REGISTRY.register(...) call — no dispatcher edits. This
870
+ # is the Phase-3 "MCP as a hub" seam (docs/rag-architecture.md §8).
871
+
872
+ REGISTRY = mcp.Registry()
873
+
874
+
875
+ def _tool_search(args: dict):
876
+ query = str(args.get("query", ""))
877
+ rag_result = rag.search(query, limit=10)
878
+ if rag_result is not None: # full-content index is built
879
+ hits, _ranker = rag_result
880
+ if not hits:
881
+ return "no skills match %r" % query, False
882
+ return "\n".join(
883
+ "%s — %s (%s)" % (h["entry"]["name"],
884
+ h["entry"].get("description", ""),
885
+ h["entry"]["tap"])
886
+ for h in hits), False
887
+ # no index yet -> keep today's frontmatter search so nothing regresses
888
+ scored = catalog.search(query)[:10]
889
+ if not scored:
890
+ return "no skills match %r" % query, False
891
+ return "\n".join("%s — %s (%s)" % (e["name"], e["description"], e["tap"])
892
+ for e, _score in scored), False
893
+
894
+
895
+ def _tool_list(args: dict):
896
+ skills = lockfile.installed()
897
+ if not skills:
898
+ return "no skills installed", False
899
+ return "\n".join("%s v%s (%s)%s"
900
+ % (n, e.get("version", "?"), e.get("tap", "?"),
901
+ " [pinned]" if e.get("pinned") else "")
902
+ for n, e in sorted(skills.items())), False
903
+
904
+
905
+ def _tool_info(args: dict):
906
+ name = str(args.get("name", ""))
907
+ entry = lockfile.get_skill(name)
908
+ matches = catalog.find(name)
909
+ if not entry and not matches:
910
+ return "no skill named %r (installed or in any tap)" % name, True
911
+ src = matches[0] if matches else {}
912
+ lines = ["name: " + name,
913
+ "version: %s" % (entry or src).get("version", "?"),
914
+ "tap: %s" % (entry or src).get("tap", "?")]
915
+ if src.get("description"):
916
+ lines.append("description: %s" % src["description"])
917
+ if entry:
918
+ lines.append("installed: yes (%s)" % entry.get("installed_at", "?"))
919
+ lines.append("agents: %s" % (", ".join(entry.get("agents") or []) or "none"))
920
+ if entry.get("pinned"):
921
+ lines.append("pinned: yes")
922
+ else:
923
+ lines.append("installed: no")
924
+ return "\n".join(lines), False
925
+
926
+
927
+ def _tool_install(args: dict):
928
+ entry = catalog.resolve_one(str(args.get("name", "")))
929
+ res = store.install(entry)
930
+ lines = ["installed %s v%s from %s → %s"
931
+ % (res.name, entry.get("version", "?"), entry["tap"], res.dest),
932
+ "linked agents: %s" % (", ".join(res.linked) or "none"),
933
+ "quality score: %d/100" % res.score]
934
+ if res.conflicts:
935
+ lines.append("conflicts (left in place): %s" % ", ".join(res.conflicts))
936
+ return "\n".join(lines), False
937
+
938
+
939
+ def _tool_doctor(args: dict):
940
+ plan = store.sync_plan()
941
+ issues = sum(len(v) for v in plan.values())
942
+ taps = registry.list_taps()
943
+ lines = ["installed skills: %d" % len(lockfile.installed()),
944
+ "taps: %d (%d skills available)" % (len(taps), len(catalog.all_entries()))]
945
+ for key, vals in plan.items():
946
+ if vals:
947
+ lines.append("%s: %s" % (key, ", ".join(str(v) for v in vals)))
948
+ lines.append("healthy — no issues found" if issues == 0
949
+ else "%d issue(s) — run `boost sync` to fix" % issues)
950
+ return "\n".join(lines), issues > 0
951
+
952
+
953
+ def _tool_discover_github(args: dict):
954
+ """Grow the corpus: GitHub code-search for SKILL.md repos (needs `gh`).
955
+
956
+ A Phase-3 reach-out tool. Degrades the ``core/ai.py`` way — if `gh` is absent
957
+ or the search fails, it returns a short helpful message instead of raising,
958
+ so the MCP server never dies on a missing external dependency.
959
+ """
960
+ from . import discovery
961
+ if not shutil.which("gh"):
962
+ return ("GitHub discovery needs the `gh` CLI — install it with "
963
+ "`brew install gh && gh auth login`, then retry."), True
964
+ query = str(args.get("query", ""))
965
+ raw = args.get("limit")
966
+ limit = int(raw) if isinstance(raw, (int, float)) and int(raw) > 0 else 20
967
+ limit = min(limit, 100)
968
+ hits = discovery.github_skill_search(query, limit)
969
+ if hits is None:
970
+ return ("GitHub code search failed — check `gh auth status` and retry."), True
971
+ if not hits:
972
+ return ("no SKILL.md repositories found on GitHub for %r"
973
+ % (query.strip() or "any query")), False
974
+ return "\n".join("%s — %s" % (h["repo"], h.get("description") or h.get("path", ""))
975
+ for h in hits), False
976
+
977
+
978
+ REGISTRY.register(
979
+ "boost_search",
980
+ "Search AI coding skills across the configured tap registries",
981
+ {"type": "object",
982
+ "properties": {"query": {"type": "string", "description": "search terms"}},
983
+ "required": ["query"]},
984
+ _tool_search)
985
+ REGISTRY.register(
986
+ "boost_list",
987
+ "List the skills currently installed by boost",
988
+ {"type": "object", "properties": {}},
989
+ _tool_list)
990
+ REGISTRY.register(
991
+ "boost_info",
992
+ "Show detailed information about one skill",
993
+ {"type": "object",
994
+ "properties": {"name": {"type": "string", "description": "skill name"}},
995
+ "required": ["name"]},
996
+ _tool_info)
997
+ REGISTRY.register(
998
+ "boost_install",
999
+ "Install a skill from a configured tap registry",
1000
+ {"type": "object",
1001
+ "properties": {"name": {"type": "string", "description": "skill name"}},
1002
+ "required": ["name"]},
1003
+ _tool_install)
1004
+ REGISTRY.register(
1005
+ "boost_doctor",
1006
+ "Health summary of the boost skill environment",
1007
+ {"type": "object", "properties": {}},
1008
+ _tool_doctor)
1009
+ REGISTRY.register(
1010
+ "boost_discover_github",
1011
+ "Discover new SKILL.md repositories on GitHub to grow the corpus (needs the "
1012
+ "`gh` CLI; degrades to a hint when unavailable)",
1013
+ {"type": "object",
1014
+ "properties": {
1015
+ "query": {"type": "string",
1016
+ "description": "optional extra search terms (topic, language)"},
1017
+ "limit": {"type": "integer",
1018
+ "description": "max repositories to return (default 20)"}}},
1019
+ _tool_discover_github)
1020
+
1021
+ # Back-compat shims: the JSON-RPC server and tests reference these names.
1022
+ _MCP_TOOLS = REGISTRY.specs()
893
1023
 
894
1024
 
895
1025
  def _mcp_tool(tool: str, args: dict):
896
1026
  """Run one MCP tool -> (text, is_error). (None, _) for unknown tools."""
897
- if tool == "boost_search":
898
- query = str(args.get("query", ""))
899
- rag_result = rag.search(query, limit=10)
900
- if rag_result is not None: # full-content index is built
901
- hits, _ranker = rag_result
902
- if not hits:
903
- return "no skills match %r" % query, False
904
- return "\n".join(
905
- "%s — %s (%s)" % (h["entry"]["name"],
906
- h["entry"].get("description", ""),
907
- h["entry"]["tap"])
908
- for h in hits), False
909
- # no index yet -> keep today's frontmatter search so nothing regresses
910
- scored = catalog.search(query)[:10]
911
- if not scored:
912
- return "no skills match %r" % query, False
913
- return "\n".join("%s — %s (%s)" % (e["name"], e["description"], e["tap"])
914
- for e, _score in scored), False
915
- if tool == "boost_list":
916
- skills = lockfile.installed()
917
- if not skills:
918
- return "no skills installed", False
919
- return "\n".join("%s v%s (%s)%s"
920
- % (n, e.get("version", "?"), e.get("tap", "?"),
921
- " [pinned]" if e.get("pinned") else "")
922
- for n, e in sorted(skills.items())), False
923
- if tool == "boost_info":
924
- name = str(args.get("name", ""))
925
- entry = lockfile.get_skill(name)
926
- matches = catalog.find(name)
927
- if not entry and not matches:
928
- return "no skill named %r (installed or in any tap)" % name, True
929
- src = matches[0] if matches else {}
930
- lines = ["name: " + name,
931
- "version: %s" % (entry or src).get("version", "?"),
932
- "tap: %s" % (entry or src).get("tap", "?")]
933
- if src.get("description"):
934
- lines.append("description: %s" % src["description"])
935
- if entry:
936
- lines.append("installed: yes (%s)" % entry.get("installed_at", "?"))
937
- lines.append("agents: %s" % (", ".join(entry.get("agents") or []) or "none"))
938
- if entry.get("pinned"):
939
- lines.append("pinned: yes")
940
- else:
941
- lines.append("installed: no")
942
- return "\n".join(lines), False
943
- if tool == "boost_install":
944
- entry = catalog.resolve_one(str(args.get("name", "")))
945
- res = store.install(entry)
946
- lines = ["installed %s v%s from %s → %s"
947
- % (res.name, entry.get("version", "?"), entry["tap"], res.dest),
948
- "linked agents: %s" % (", ".join(res.linked) or "none"),
949
- "quality score: %d/100" % res.score]
950
- if res.conflicts:
951
- lines.append("conflicts (left in place): %s" % ", ".join(res.conflicts))
952
- return "\n".join(lines), False
953
- if tool == "boost_doctor":
954
- plan = store.sync_plan()
955
- issues = sum(len(v) for v in plan.values())
956
- taps = registry.list_taps()
957
- lines = ["installed skills: %d" % len(lockfile.installed()),
958
- "taps: %d (%d skills available)" % (len(taps), len(catalog.all_entries()))]
959
- for key, vals in plan.items():
960
- if vals:
961
- lines.append("%s: %s" % (key, ", ".join(str(v) for v in vals)))
962
- lines.append("healthy — no issues found" if issues == 0
963
- else "%d issue(s) — run `boost sync` to fix" % issues)
964
- return "\n".join(lines), issues > 0
965
- return None, False
1027
+ return REGISTRY.call(tool, args)
966
1028
 
967
1029
 
968
1030
  def _mcp_send(msg: dict) -> bool:
@@ -13,6 +13,7 @@ import shutil
13
13
  import subprocess
14
14
  import sys
15
15
  import time
16
+ import urllib.parse
16
17
  from pathlib import Path
17
18
 
18
19
  from .. import cliparse, spin
@@ -350,6 +351,47 @@ def cmd_index(argv):
350
351
  return 0
351
352
 
352
353
 
354
+ def github_skill_search(query="", limit=20):
355
+ """One-shot GitHub code search for SKILL.md repos (Phase-3 MCP reach-out).
356
+
357
+ Returns a list of ``{repo, path, url, description}`` dicts, or ``None`` when
358
+ the `gh` CLI is unavailable or the search fails — so callers degrade
359
+ gracefully instead of raising. Unlike :func:`cmd_index` this fetches a single
360
+ page and writes no cache: it is sized for an interactive MCP call, not a full
361
+ corpus build.
362
+ """
363
+ if not shutil.which("gh"):
364
+ return None
365
+ q = "filename:SKILL.md"
366
+ if query and query.strip():
367
+ q += " " + query.strip()
368
+ per_page = max(1, min(int(limit), 100))
369
+ try:
370
+ proc = subprocess.run(
371
+ ["gh", "api", "-H", "Accept: application/vnd.github+json",
372
+ "search/code?q=%s&per_page=%d&page=1"
373
+ % (urllib.parse.quote(q, safe=":"), per_page)],
374
+ capture_output=True, text=True, timeout=120)
375
+ except (subprocess.TimeoutExpired, OSError):
376
+ return None
377
+ if proc.returncode != 0:
378
+ return None
379
+ try:
380
+ data = json.loads(proc.stdout)
381
+ except json.JSONDecodeError:
382
+ return None
383
+ items = []
384
+ for it in (data.get("items") or [])[:per_page]:
385
+ repo = it.get("repository") or {}
386
+ items.append({
387
+ "repo": repo.get("full_name", "?"),
388
+ "path": it.get("path", ""),
389
+ "url": it.get("html_url", ""),
390
+ "description": repo.get("description") or "",
391
+ })
392
+ return items
393
+
394
+
353
395
  def cmd_discover(argv):
354
396
  """Browse/filter the GitHub-wide index built by `boost index`."""
355
397
  p = cliparse.parser(
@@ -0,0 +1,70 @@
1
+ """Extensible MCP tool registry — the Phase-3 "MCP as a hub" seam.
2
+
3
+ An MCP tool is a *handler* — ``fn(args: dict) -> (text, is_error)`` — paired with
4
+ its JSON spec (``name`` / ``description`` / ``inputSchema``). Instead of a flat
5
+ ``if/elif`` dispatcher, tools self-register on a :class:`Registry`; the JSON-RPC
6
+ server iterates it for both ``tools/list`` and ``tools/call``. Adding a
7
+ capability (a GitHub reach-out, a database query, some future dependency) is one
8
+ ``register()`` call — no dispatcher edits, no server changes.
9
+
10
+ Handlers return ``(text, is_error)``. A handler that returns ``text is None``
11
+ signals "I did not produce a result"; :meth:`Registry.call` also returns
12
+ ``(None, False)`` for an unknown tool, so the server can answer with a JSON-RPC
13
+ ``unknown tool`` error in exactly one place. Reach-out handlers degrade the way
14
+ ``core/ai.py`` does: probe for the dependency, and when it is absent return a
15
+ short helpful message rather than raising.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ from typing import Callable, Dict, List, Optional, Tuple
20
+
21
+ # A handler maps parsed arguments to (text, is_error). ``text is None`` means the
22
+ # handler produced no result (treated by the server as an unknown/aborted tool).
23
+ Handler = Callable[[dict], Tuple[Optional[str], bool]]
24
+
25
+
26
+ class Registry:
27
+ """An ordered name -> (spec, handler) map for MCP tools."""
28
+
29
+ def __init__(self) -> None:
30
+ self._order: List[str] = []
31
+ self._specs: Dict[str, dict] = {}
32
+ self._handlers: Dict[str, Handler] = {}
33
+
34
+ def register(self, name: str, description: str, input_schema: dict,
35
+ handler: Handler) -> None:
36
+ """Register one tool. Raises on an empty or duplicate name."""
37
+ if not name:
38
+ raise ValueError("MCP tool name must be non-empty")
39
+ if name in self._handlers:
40
+ raise ValueError("duplicate MCP tool %r" % name)
41
+ self._order.append(name)
42
+ self._specs[name] = {"name": name, "description": description,
43
+ "inputSchema": input_schema}
44
+ self._handlers[name] = handler
45
+
46
+ def tool(self, name: str, description: str,
47
+ input_schema: dict) -> Callable[[Handler], Handler]:
48
+ """Decorator form of :meth:`register`; returns the handler unchanged."""
49
+ def deco(fn: Handler) -> Handler:
50
+ self.register(name, description, input_schema, fn)
51
+ return fn
52
+ return deco
53
+
54
+ def specs(self) -> List[dict]:
55
+ """The ``tools/list`` payload — specs in registration order."""
56
+ return [self._specs[name] for name in self._order]
57
+
58
+ def names(self) -> List[str]:
59
+ """Registered tool names, in registration order."""
60
+ return list(self._order)
61
+
62
+ def has(self, name: str) -> bool:
63
+ return name in self._handlers
64
+
65
+ def call(self, name: str, args: dict) -> Tuple[Optional[str], bool]:
66
+ """Dispatch to a handler. ``(None, False)`` for an unknown tool."""
67
+ handler = self._handlers.get(name)
68
+ if handler is None:
69
+ return None, False
70
+ return handler(args)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: boost-skill-cli
3
- Version: 1.0.51
3
+ Version: 1.0.52
4
4
  Summary: boost — Homebrew for AI coding skills
5
5
  Author: Jonathan Reyes
6
6
  License: GNU GENERAL PUBLIC LICENSE
@@ -41,6 +41,7 @@ boost_cli/core/gitutil.py
41
41
  boost_cli/core/journal.py
42
42
  boost_cli/core/lockfile.py
43
43
  boost_cli/core/logs.py
44
+ boost_cli/core/mcp.py
44
45
  boost_cli/core/output.py
45
46
  boost_cli/core/paths.py
46
47
  boost_cli/core/policy.py
@@ -97,6 +98,7 @@ tests/unit/test_gitutil.py
97
98
  tests/unit/test_journal.py
98
99
  tests/unit/test_lockfile.py
99
100
  tests/unit/test_logs.py
101
+ tests/unit/test_mcp.py
100
102
  tests/unit/test_mutation_hardening.py
101
103
  tests/unit/test_output.py
102
104
  tests/unit/test_paths.py
@@ -194,23 +194,41 @@ dense retrieval.
194
194
 
195
195
  ---
196
196
 
197
- ## 8. Phase 3 — MCP as a hub (GitHub, database, other deps)
197
+ ## 8. Phase 3 — MCP as a hub (GitHub, database, other deps) — ✅ shipped
198
+
199
+ The flat `if/elif` dispatcher is now an **extensible registry** (`boost_cli/core/mcp.py`):
200
+
201
+ 1. **Registry refactor (done).** `core/mcp.py` defines a `Registry` — an ordered `name -> (spec, handler)`
202
+ map. Each handler is `fn(args: dict) -> (text, is_error)` and is paired with its
203
+ `{name, description, inputSchema}` via `REGISTRY.register(...)` (or the `@REGISTRY.tool(...)`
204
+ decorator). The JSON-RPC server iterates it: `tools/list` returns `REGISTRY.specs()` and `tools/call`
205
+ dispatches through `REGISTRY.call(name, args)` — an unknown tool returns `(None, False)` in exactly one
206
+ place, so the server answers a single `-32602 unknown tool` error. `_MCP_TOOLS` and `_mcp_tool` remain
207
+ as thin back-compat shims over the registry.
208
+ 2. **First reach-out tool (done), behind graceful degradation** (the `core/ai.py` pattern — probe, else a
209
+ helpful message, never raise):
210
+ - **`boost_discover_github`** — GitHub code-search for `SKILL.md` repos to grow the corpus, via the
211
+ one-shot `discovery.github_skill_search(query, limit)` helper. No `gh` CLI → returns an install hint
212
+ with `is_error=True`; a failed search → a retry hint; otherwise a ranked repo list. It never writes a
213
+ cache and never crashes the server.
214
+ - Future tools (Phase-2 vector store / a database query, a generic external API) plug in the same way.
215
+
216
+ **Extension contract** — to add an MCP tool, no dispatcher or server edit is needed:
198
217
 
199
- Today the MCP tool layer is a declarative list `_MCP_TOOLS` (`configuration.py:867`) plus a flat
200
- `if/elif` dispatcher `_mcp_tool` (`configuration.py:895`). To make new capabilities drop-in:
218
+ ```python
219
+ def _tool_x(args: dict) -> tuple[str | None, bool]:
220
+ # probe for any external dependency; degrade to a helpful message if absent
221
+ return "…result text…", False # (text, is_error)
222
+
223
+ REGISTRY.register("boost_x", "one-line description",
224
+ {"type": "object", "properties": {…}}, _tool_x)
225
+ ```
201
226
 
202
- 1. **Registry refactor.** Replace the `if/elif` with a `name -> handler` map; each handler is
203
- `fn(args) -> (text, is_error)` and self-registers its `{name, description, inputSchema}`. `tools/list`
204
- and `tools/call` (`_mcp_serve_stdio`, `configuration.py:965`) iterate the registry unchanged.
205
- 2. **Reach-out tools, each behind graceful degradation** (the `core/ai.py` pattern — probe, else no-op
206
- with a helpful message):
207
- - `boost_discover_github` — grow the corpus by wrapping the existing `gh`-based `cmd_index`
208
- (`discovery.py:217`) + auto-tap of promising repos.
209
- - `boost_semantic_search` capabilities backed by the Phase-2 vector store / a database once present.
210
- - a documented **extension contract** so any future dependency (a DB, an external API) becomes one new
211
- handler file, no dispatcher edits.
227
+ A handler that reaches out to a dependency **must** degrade (return a short message, not raise) so the
228
+ long-lived stdio server survives a missing `gh`, offline network, or absent `[rag]` extra. Registration
229
+ order is preserved and is the `tools/list` order.
212
230
 
213
- Phase 3 is intentionally *after* the retrieval core is proven, so the hub grows on a stable seam.
231
+ Phase 3 landed *after* the retrieval core was proven, so the hub grew on a stable seam.
214
232
 
215
233
  ---
216
234
 
@@ -235,7 +253,7 @@ and RAG indexing compose automatically.
235
253
  |---|---|---|---|---|
236
254
  | **1** | Full-content BM25 retrieval + LLM rerank; `boost_search` upgraded in place; `boost reindex` | **none** (stdlib) | ✅ yes | New `core/rag.py` (mutation-gated) + tests; coverage ≥80%; ruff/mypy/smoke unaffected |
237
255
  | **2** | Dense embeddings + vector store | `[rag]` extra only | ❌ opt-in | Extra-only; default path unchanged; TF-IDF fallback keeps green |
238
- | **3** | MCP hub: GitHub/DB reach-out tools, registry refactor | none required (deps per tool, opt-in) | ❌ opt-in | Registry refactor covered by existing `TestMcp`; new handlers add tests |
256
+ | **3** ✅ | MCP hub: extensible `core/mcp.py` registry + `boost_discover_github` reach-out | none required (deps per tool, opt-in) | ❌ opt-in | New `core/mcp.py` (mutation-gated) + `test_mcp.py`; `TestMcp`/`TestGithubSkillSearch` cover the tool; coverage 93% |
239
257
 
240
258
  **Invariant preserved at every phase:** `pip install boost-skill-cli` remains zero-runtime-dependency and
241
259
  `make check` stays green; all heavier machinery is opt-in behind the `[rag]` extra and the
@@ -650,7 +650,7 @@ class TestMcp:
650
650
  tools = by_id[2]["result"]["tools"]
651
651
  assert [t["name"] for t in tools] == [
652
652
  "boost_search", "boost_list", "boost_info", "boost_install",
653
- "boost_doctor"]
653
+ "boost_doctor", "boost_discover_github"]
654
654
  assert all(t["inputSchema"]["type"] == "object" for t in tools)
655
655
 
656
656
  def text(id_):
@@ -703,6 +703,59 @@ class TestMcp:
703
703
  assert is_err is False
704
704
  assert "no skills match 'zzzznothing'" in text
705
705
 
706
+ def test_registry_dispatches_and_lists_all_tools(self, sandbox):
707
+ from boost_cli.commands import configuration
708
+ # tools/list payload and the dispatcher share one registry
709
+ assert configuration._MCP_TOOLS == configuration.REGISTRY.specs()
710
+ assert "boost_discover_github" in configuration.REGISTRY.names()
711
+ assert configuration._mcp_tool("nonexistent_tool", {}) == (None, False)
712
+
713
+ def test_discover_github_missing_gh_degrades(self, sandbox, monkeypatch):
714
+ from boost_cli.commands import configuration
715
+ monkeypatch.setattr("boost_cli.commands.configuration.shutil.which",
716
+ lambda c: None)
717
+ text, is_err = configuration._mcp_tool("boost_discover_github",
718
+ {"query": "react"})
719
+ assert is_err is True
720
+ assert "gh" in text and "brew install gh" in text
721
+
722
+ def test_discover_github_lists_repos(self, sandbox, monkeypatch):
723
+ from boost_cli.commands import configuration, discovery
724
+ monkeypatch.setattr("boost_cli.commands.configuration.shutil.which",
725
+ lambda c: "/usr/bin/gh")
726
+ monkeypatch.setattr(
727
+ discovery, "github_skill_search",
728
+ lambda query="", limit=20: [
729
+ {"repo": "octo/skills", "path": "a/SKILL.md", "url": "u",
730
+ "description": "great skills"},
731
+ {"repo": "acme/pack", "path": "b/SKILL.md", "url": "u",
732
+ "description": ""}])
733
+ text, is_err = configuration._mcp_tool("boost_discover_github",
734
+ {"query": "react", "limit": 5})
735
+ assert is_err is False
736
+ assert "octo/skills — great skills" in text
737
+ assert "acme/pack — b/SKILL.md" in text # falls back to path
738
+
739
+ def test_discover_github_no_results(self, sandbox, monkeypatch):
740
+ from boost_cli.commands import configuration, discovery
741
+ monkeypatch.setattr("boost_cli.commands.configuration.shutil.which",
742
+ lambda c: "/usr/bin/gh")
743
+ monkeypatch.setattr(discovery, "github_skill_search",
744
+ lambda query="", limit=20: [])
745
+ text, is_err = configuration._mcp_tool("boost_discover_github", {})
746
+ assert is_err is False
747
+ assert "no SKILL.md repositories found" in text
748
+
749
+ def test_discover_github_search_failure(self, sandbox, monkeypatch):
750
+ from boost_cli.commands import configuration, discovery
751
+ monkeypatch.setattr("boost_cli.commands.configuration.shutil.which",
752
+ lambda c: "/usr/bin/gh")
753
+ monkeypatch.setattr(discovery, "github_skill_search",
754
+ lambda query="", limit=20: None)
755
+ text, is_err = configuration._mcp_tool("boost_discover_github", {})
756
+ assert is_err is True
757
+ assert "GitHub code search failed" in text
758
+
706
759
  def test_register_without_claude_prints_manual_command(self, boost, sandbox,
707
760
  monkeypatch):
708
761
  monkeypatch.setattr("boost_cli.commands.configuration.shutil.which",
@@ -276,6 +276,101 @@ class TestIndex:
276
276
  assert "gh api timed out on page 1" in r.err
277
277
 
278
278
 
279
+ class TestGithubSkillSearch:
280
+ """The one-shot reach-out helper behind the boost_discover_github MCP tool."""
281
+
282
+ def test_none_without_gh(self, sandbox, monkeypatch):
283
+ from boost_cli.commands import discovery
284
+ monkeypatch.setattr("boost_cli.commands.discovery.shutil.which",
285
+ lambda c: None)
286
+ assert discovery.github_skill_search("react") is None
287
+
288
+ def test_returns_mapped_items(self, sandbox, monkeypatch):
289
+ from boost_cli.commands import discovery
290
+ monkeypatch.setattr("boost_cli.commands.discovery.shutil.which",
291
+ lambda c: "/usr/bin/gh")
292
+ seen = []
293
+
294
+ def fake_run(cmd, **kw):
295
+ seen.append(cmd)
296
+ return types.SimpleNamespace(returncode=0, stderr="", stdout=_gh_page(
297
+ [_gh_item("octo/skills", "a/SKILL.md"),
298
+ _gh_item("acme/pack", "b/SKILL.md", desc="")]))
299
+
300
+ monkeypatch.setattr("boost_cli.commands.discovery.subprocess.run", fake_run)
301
+ out = discovery.github_skill_search("react hooks", limit=5)
302
+ assert out == [
303
+ {"repo": "octo/skills", "path": "a/SKILL.md",
304
+ "url": "https://github.com/octo/skills/a/SKILL.md",
305
+ "description": "Skill repo"},
306
+ {"repo": "acme/pack", "path": "b/SKILL.md",
307
+ "url": "https://github.com/acme/pack/b/SKILL.md", "description": ""}]
308
+ # single page, user query appended (url-encoded) to the filename filter
309
+ assert "per_page=5&page=1" in seen[0][-1]
310
+ assert "filename:SKILL.md" in seen[0][-1]
311
+ assert "react" in seen[0][-1] and "hooks" in seen[0][-1]
312
+
313
+ def test_no_query_uses_bare_filename_filter(self, sandbox, monkeypatch):
314
+ from boost_cli.commands import discovery
315
+ monkeypatch.setattr("boost_cli.commands.discovery.shutil.which",
316
+ lambda c: "/usr/bin/gh")
317
+ seen = []
318
+ monkeypatch.setattr(
319
+ "boost_cli.commands.discovery.subprocess.run",
320
+ lambda cmd, **kw: seen.append(cmd) or types.SimpleNamespace(
321
+ returncode=0, stderr="", stdout=_gh_page([])))
322
+ assert discovery.github_skill_search() == []
323
+ assert "q=filename:SKILL.md&per_page=20&page=1" in seen[0][-1]
324
+
325
+ def test_limit_capped_and_sliced(self, sandbox, monkeypatch):
326
+ from boost_cli.commands import discovery
327
+ monkeypatch.setattr("boost_cli.commands.discovery.shutil.which",
328
+ lambda c: "/usr/bin/gh")
329
+ seen = []
330
+ items = [_gh_item("o/r%d" % i, "s/SKILL.md") for i in range(10)]
331
+ monkeypatch.setattr(
332
+ "boost_cli.commands.discovery.subprocess.run",
333
+ lambda cmd, **kw: seen.append(cmd) or types.SimpleNamespace(
334
+ returncode=0, stderr="", stdout=_gh_page(items)))
335
+ out = discovery.github_skill_search("x", limit=3)
336
+ assert len(out) == 3 # sliced to per_page
337
+ assert "per_page=3&page=1" in seen[0][-1]
338
+ # a limit above the code-search cap is clamped to 100
339
+ discovery.github_skill_search("x", limit=9999)
340
+ assert "per_page=100&page=1" in seen[1][-1]
341
+
342
+ def test_gh_failure_returns_none(self, sandbox, monkeypatch):
343
+ from boost_cli.commands import discovery
344
+ monkeypatch.setattr("boost_cli.commands.discovery.shutil.which",
345
+ lambda c: "/usr/bin/gh")
346
+ monkeypatch.setattr(
347
+ "boost_cli.commands.discovery.subprocess.run",
348
+ lambda cmd, **kw: types.SimpleNamespace(
349
+ returncode=1, stdout="", stderr="404"))
350
+ assert discovery.github_skill_search("x") is None
351
+
352
+ def test_bad_json_returns_none(self, sandbox, monkeypatch):
353
+ from boost_cli.commands import discovery
354
+ monkeypatch.setattr("boost_cli.commands.discovery.shutil.which",
355
+ lambda c: "/usr/bin/gh")
356
+ monkeypatch.setattr(
357
+ "boost_cli.commands.discovery.subprocess.run",
358
+ lambda cmd, **kw: types.SimpleNamespace(
359
+ returncode=0, stdout="not json", stderr=""))
360
+ assert discovery.github_skill_search("x") is None
361
+
362
+ def test_oserror_returns_none(self, sandbox, monkeypatch):
363
+ from boost_cli.commands import discovery
364
+ monkeypatch.setattr("boost_cli.commands.discovery.shutil.which",
365
+ lambda c: "/usr/bin/gh")
366
+
367
+ def boom(cmd, **kw):
368
+ raise OSError("no exec")
369
+
370
+ monkeypatch.setattr("boost_cli.commands.discovery.subprocess.run", boom)
371
+ assert discovery.github_skill_search("x") is None
372
+
373
+
279
374
  # ---------------------------------------------------------------- discover
280
375
 
281
376
  def _write_index(items, total=42):
@@ -0,0 +1,83 @@
1
+ """Unit tests: boost_cli/core/mcp.py — the extensible MCP tool registry.
2
+
3
+ The registry is the Phase-3 "MCP as a hub" seam: tools self-register a spec +
4
+ handler, and the JSON-RPC server iterates it. These tests pin registration
5
+ order, spec shape, dispatch, and the unknown-tool contract so the mutation gate
6
+ has teeth.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import pytest
11
+
12
+ from boost_cli.core import mcp
13
+
14
+
15
+ def _ok(_args):
16
+ return "ok", False
17
+
18
+
19
+ class TestRegister:
20
+ def test_spec_shape_and_order(self):
21
+ reg = mcp.Registry()
22
+ reg.register("a", "does a", {"type": "object"}, _ok)
23
+ reg.register("b", "does b", {"type": "object", "properties": {}},
24
+ lambda a: ("b!", False))
25
+ assert reg.names() == ["a", "b"] # registration order
26
+ specs = reg.specs()
27
+ assert [s["name"] for s in specs] == ["a", "b"]
28
+ assert specs[0] == {"name": "a", "description": "does a",
29
+ "inputSchema": {"type": "object"}}
30
+ assert specs[1]["inputSchema"] == {"type": "object", "properties": {}}
31
+
32
+ def test_specs_preserve_insertion_not_sorted(self):
33
+ reg = mcp.Registry()
34
+ for n in ("zebra", "alpha", "mid"):
35
+ reg.register(n, n, {"type": "object"}, _ok)
36
+ assert reg.names() == ["zebra", "alpha", "mid"] # not alphabetized
37
+
38
+ def test_empty_name_rejected(self):
39
+ reg = mcp.Registry()
40
+ with pytest.raises(ValueError, match="non-empty"):
41
+ reg.register("", "d", {"type": "object"}, _ok)
42
+
43
+ def test_duplicate_name_rejected(self):
44
+ reg = mcp.Registry()
45
+ reg.register("a", "d", {"type": "object"}, _ok)
46
+ with pytest.raises(ValueError, match="duplicate"):
47
+ reg.register("a", "d2", {"type": "object"}, _ok)
48
+ assert reg.names() == ["a"] # first wins, unchanged
49
+
50
+ def test_decorator_registers_and_returns_fn(self):
51
+ reg = mcp.Registry()
52
+
53
+ @reg.tool("greet", "say hi", {"type": "object"})
54
+ def greet(args):
55
+ return "hi %s" % args.get("who", "?"), False
56
+
57
+ assert reg.has("greet")
58
+ assert callable(greet) # returned unchanged
59
+ assert greet({"who": "x"}) == ("hi x", False)
60
+ assert reg.call("greet", {"who": "y"}) == ("hi y", False)
61
+
62
+
63
+ class TestDispatch:
64
+ def test_call_routes_to_handler(self):
65
+ reg = mcp.Registry()
66
+ seen = {}
67
+ reg.register("echo", "d", {"type": "object"},
68
+ lambda a: (seen.update(a) or "done", bool(a.get("bad"))))
69
+ assert reg.call("echo", {"x": 1}) == ("done", False)
70
+ assert seen == {"x": 1}
71
+ assert reg.call("echo", {"bad": 1})[1] is True # is_error propagates
72
+
73
+ def test_unknown_tool_is_none_false(self):
74
+ reg = mcp.Registry()
75
+ reg.register("a", "d", {"type": "object"}, _ok)
76
+ assert reg.call("nope", {}) == (None, False)
77
+ assert reg.has("nope") is False
78
+ assert reg.has("a") is True
79
+
80
+ def test_handler_none_text_passes_through(self):
81
+ reg = mcp.Registry()
82
+ reg.register("silent", "d", {"type": "object"}, lambda a: (None, True))
83
+ assert reg.call("silent", {}) == (None, True) # not masked as unknown
File without changes