@heytherevibin/skillforge 0.10.0 → 0.11.7
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.
- package/CHANGELOG.md +53 -0
- package/CONTRIBUTING.md +5 -3
- package/README.md +37 -345
- package/RELEASING.md +8 -7
- package/STRATEGY.md +2 -2
- package/bin/cli.js +297 -52
- package/ci/test-user-env-profile.cjs +65 -0
- package/docs/README.md +14 -0
- package/docs/architecture-and-data.md +90 -0
- package/docs/cli-reference.md +57 -0
- package/docs/environment-and-configuration.md +76 -0
- package/docs/getting-started.md +88 -0
- package/docs/mcp-integration.md +75 -0
- package/docs/troubleshooting.md +50 -0
- package/lib/templates/claude-code-skillforge-global.md +3 -3
- package/lib/templates/cursor-skillforge-global.md +6 -2
- package/lib/user-env-profile.js +141 -0
- package/package.json +3 -2
- package/python/app/agent_cli.py +334 -0
- package/python/app/explain_route.py +170 -0
- package/python/app/health_cli.py +13 -0
- package/python/app/main.py +131 -48
- package/python/app/materialize.py +150 -68
- package/python/app/mcp_contract.py +2 -1
- package/python/app/mcp_operator.py +252 -0
- package/python/app/mcp_server.py +290 -118
- package/python/app/npm_pkg_version.py +38 -0
- package/python/app/pick_diversify.py +51 -0
- package/python/app/replay_cli.py +145 -0
- package/python/app/route_cli.py +251 -87
- package/python/app/route_cli_pick.py +35 -0
- package/python/app/route_policies.py +18 -3
- package/python/app/route_quality.py +70 -1
- package/python/app/router_llm.py +85 -0
- package/python/app/router_mode.py +21 -0
- package/python/app/routing_signals.py +7 -1
- package/python/app/skill_manifest.py +67 -0
- package/python/app/skills_author_cli.py +117 -0
- package/python/app/tips_cli.py +37 -0
- package/python/app/tools_cli.py +276 -0
- package/python/fixtures/route_eval/smoke.json +5 -0
- package/python/requirements.txt +1 -0
- package/python/tests/test_capabilities_bundle.py +33 -0
- package/python/tests/test_materialize_hosts.py +108 -0
- package/python/tests/test_mcp_contract.py +1 -1
- package/python/tests/test_mcp_initialize_clientinfo.py +26 -0
- package/python/tests/test_mcp_operator.py +84 -0
- package/python/tests/test_npm_pkg_version.py +21 -0
- package/python/tests/test_pick_diversify.py +47 -0
- package/python/tests/test_replay_cli.py +31 -0
- package/python/tests/test_route_cli_pick.py +25 -0
- package/python/tests/test_route_policies.py +29 -0
- package/python/tests/test_route_quality.py +72 -0
- package/python/tests/test_router_llm.py +63 -0
- package/python/tests/test_router_mode_env.py +21 -0
- package/python/tests/test_routing_signals.py +20 -0
- package/python/tests/test_skill_manifest.py +48 -0
- package/python/tests/test_tools_cli.py +69 -0
package/python/app/mcp_server.py
CHANGED
|
@@ -9,6 +9,8 @@ Tools exposed:
|
|
|
9
9
|
search_skills / explain_route / get_skill — retrieval, debugging, deterministic fetch.
|
|
10
10
|
materialize_project — .cursor/rules, docs/SKILLFORGE-PRD.md, CLAUDE.md block.
|
|
11
11
|
list_skills, skill_feedback, skill_referenced, disable_skill.
|
|
12
|
+
capabilities — bundled snapshot (semver, MCP schema version, tool names, user_env_profile commands, router_snapshot) for session start.
|
|
13
|
+
get_router_status, project_index_status, weights_snapshot, events_recent — read-only operator introspection.
|
|
12
14
|
|
|
13
15
|
Run as: python -m app.mcp_server
|
|
14
16
|
Speaks MCP over stdio (the protocol's standard transport for local servers).
|
|
@@ -26,7 +28,6 @@ from pathlib import Path
|
|
|
26
28
|
from app.db_paths import resolve_orchestrator_db
|
|
27
29
|
from app.main import (
|
|
28
30
|
TOP_K_CANDIDATES,
|
|
29
|
-
MAX_ACTIVE_SKILLS,
|
|
30
31
|
SKILLFORGE_ROUTER_MODE,
|
|
31
32
|
build_router_and_skills,
|
|
32
33
|
format_context_items_markdown,
|
|
@@ -39,17 +40,28 @@ from app.main import (
|
|
|
39
40
|
update_skill_stat,
|
|
40
41
|
Router,
|
|
41
42
|
)
|
|
42
|
-
from app.
|
|
43
|
+
from app.explain_route import compute_explain_route
|
|
44
|
+
from app.materialize import materialize_project_files, resolve_materialize_hosts_argument
|
|
43
45
|
from app.mcp_contract import MCP_RESPONSE_SCHEMA_VERSION, build_route_skills_meta
|
|
46
|
+
from app.mcp_operator import (
|
|
47
|
+
EVENTS_META_ROW_CAP,
|
|
48
|
+
build_capabilities_bundle,
|
|
49
|
+
build_router_status_dict,
|
|
50
|
+
events_recent_rows,
|
|
51
|
+
format_capabilities_markdown,
|
|
52
|
+
format_events_markdown,
|
|
53
|
+
format_project_index_markdown,
|
|
54
|
+
format_router_status_markdown,
|
|
55
|
+
project_index_status_dict,
|
|
56
|
+
)
|
|
57
|
+
from app.npm_pkg_version import published_package_version
|
|
44
58
|
from app.redaction import redaction_enabled, redact_display_path
|
|
45
59
|
from app.route_policies import (
|
|
46
|
-
build_routing_overlay_payload,
|
|
47
60
|
load_route_policies_config,
|
|
48
|
-
merge_policy_includes,
|
|
49
61
|
merge_project_notes_into_route_query,
|
|
50
62
|
parse_routing_overlay,
|
|
51
63
|
)
|
|
52
|
-
from app.routing_signals import build_route_query_text
|
|
64
|
+
from app.routing_signals import build_route_query_text, skill_routing_card
|
|
53
65
|
|
|
54
66
|
|
|
55
67
|
def _env_truthy(name: str, default: str = "1") -> bool:
|
|
@@ -94,6 +106,8 @@ class MCPServer:
|
|
|
94
106
|
self._catalog_manifest: tuple[tuple[str, int], ...] | None = None
|
|
95
107
|
self._reload_lock: asyncio.Lock | None = None
|
|
96
108
|
self._db_cache: dict[str, sqlite3.Connection] = {}
|
|
109
|
+
self._mcp_client_name = ""
|
|
110
|
+
self._mcp_client_title = ""
|
|
97
111
|
|
|
98
112
|
def _mcp_user_id(self, args: dict) -> str:
|
|
99
113
|
"""Per-tool user namespace for weights/sessions/events."""
|
|
@@ -141,10 +155,10 @@ class MCPServer:
|
|
|
141
155
|
asyncio.create_task(self._watch_skills_poll_loop(interval))
|
|
142
156
|
|
|
143
157
|
def _reload_catalog_sync(self):
|
|
144
|
-
skills = load_all_skills()
|
|
158
|
+
skills = load_all_skills(manifest_log_prefix="[skillforge-mcp]")
|
|
145
159
|
embed_model = self.router.embed_model
|
|
146
|
-
|
|
147
|
-
self.router = Router(skills, embed_model,
|
|
160
|
+
router_llm = self.router.router_llm
|
|
161
|
+
self.router = Router(skills, embed_model, router_llm)
|
|
148
162
|
self.skills = {s.name: s for s in skills}
|
|
149
163
|
print(f"[skillforge-mcp] Hot-reloaded {len(skills)} skills", file=sys.stderr)
|
|
150
164
|
|
|
@@ -190,13 +204,20 @@ class MCPServer:
|
|
|
190
204
|
# ---- MCP handlers ----
|
|
191
205
|
|
|
192
206
|
def handle_initialize(self, params):
|
|
207
|
+
ci = params.get("clientInfo")
|
|
208
|
+
if isinstance(ci, dict):
|
|
209
|
+
self._mcp_client_name = str(ci.get("name") or "").strip()
|
|
210
|
+
self._mcp_client_title = str(ci.get("title") or "").strip()
|
|
211
|
+
else:
|
|
212
|
+
self._mcp_client_name = ""
|
|
213
|
+
self._mcp_client_title = ""
|
|
193
214
|
caps: dict = {"tools": {}}
|
|
194
215
|
if _mcp_tools_list_changed_capability():
|
|
195
216
|
caps["tools"]["listChanged"] = True
|
|
196
217
|
return {
|
|
197
218
|
"protocolVersion": "2024-11-05",
|
|
198
219
|
"capabilities": caps,
|
|
199
|
-
"serverInfo": {"name": "skillforge", "version":
|
|
220
|
+
"serverInfo": {"name": "skillforge", "version": published_package_version()},
|
|
200
221
|
}
|
|
201
222
|
|
|
202
223
|
def handle_tools_list(self, params):
|
|
@@ -205,12 +226,12 @@ class MCPServer:
|
|
|
205
226
|
{
|
|
206
227
|
"name": "route_skills",
|
|
207
228
|
"description": (
|
|
208
|
-
"
|
|
229
|
+
"Default SKILLFORGE_ROUTER_MODE=host (two-step, no in-process router LLM): (1) call with prompt "
|
|
209
230
|
"only — returns a tight numbered shortlist + session_id; (2) call again with the same prompt "
|
|
210
231
|
"and picked_names (JSON array of exact catalog ids from the list) to load SKILL.md chunks. "
|
|
211
|
-
"
|
|
212
|
-
"include_project_rag. picked_names may also be passed
|
|
213
|
-
"auto-pick and use the host-provided list."
|
|
232
|
+
"Set SKILLFORGE_ROUTER_MODE=auto (or embedding/full) for one-call routing when configured. "
|
|
233
|
+
"Optional conversation, project_root, include_project_rag. picked_names may also be passed "
|
|
234
|
+
"in embedding/full/auto to skip auto-pick and use the host-provided list."
|
|
214
235
|
),
|
|
215
236
|
"inputSchema": {
|
|
216
237
|
"type": "object",
|
|
@@ -299,7 +320,8 @@ class MCPServer:
|
|
|
299
320
|
{
|
|
300
321
|
"name": "get_skill",
|
|
301
322
|
"description": (
|
|
302
|
-
"Load one skill by name: full SKILL.md body
|
|
323
|
+
"Load one skill by name: full SKILL.md body, short summary (~8k opener), "
|
|
324
|
+
"or routing `card` (title/description/triggers only). "
|
|
303
325
|
"Use for deterministic workflows when you already know the skill name."
|
|
304
326
|
),
|
|
305
327
|
"inputSchema": {
|
|
@@ -308,8 +330,11 @@ class MCPServer:
|
|
|
308
330
|
"skill_name": {"type": "string"},
|
|
309
331
|
"format": {
|
|
310
332
|
"type": "string",
|
|
311
|
-
"enum": ["
|
|
312
|
-
"description":
|
|
333
|
+
"enum": ["card", "summary", "full"],
|
|
334
|
+
"description": (
|
|
335
|
+
"card = routing-style card (minimal tokens); summary = description + first ~8k of body; "
|
|
336
|
+
"full = entire SKILL.md body"
|
|
337
|
+
),
|
|
313
338
|
"default": "full",
|
|
314
339
|
},
|
|
315
340
|
"max_chars": {
|
|
@@ -386,12 +411,12 @@ class MCPServer:
|
|
|
386
411
|
{
|
|
387
412
|
"name": "materialize_project",
|
|
388
413
|
"description": (
|
|
389
|
-
"Write project-local Skillforge files
|
|
390
|
-
"
|
|
391
|
-
".
|
|
392
|
-
"
|
|
393
|
-
"
|
|
394
|
-
"
|
|
414
|
+
"Write project-local Skillforge files under project_root. Default hosts=auto: SKILLFORGE_MATERIALIZE_HOSTS "
|
|
415
|
+
"if set, else infer Cursor vs Claude from MCP initialize clientInfo, else both. Cursor "
|
|
416
|
+
"(.cursor/rules + .cursor/commands), Claude Code (.claude/commands), docs/SKILLFORGE-PRD.md, "
|
|
417
|
+
"and CLAUDE.md markers. hosts=cursor: Cursor + docs only. hosts=claude_code: Claude + docs + CLAUDE.md "
|
|
418
|
+
"(no .cursor/). hosts=both: write all host trees. With hosts=auto, SKILLFORGE_MATERIALIZE_HOSTS overrides "
|
|
419
|
+
"client inference when set. Pass skill_names from route_skills."
|
|
395
420
|
),
|
|
396
421
|
"inputSchema": {
|
|
397
422
|
"type": "object",
|
|
@@ -402,12 +427,21 @@ class MCPServer:
|
|
|
402
427
|
"items": {"type": "string"},
|
|
403
428
|
"description": "Skill names from the last route_skills result",
|
|
404
429
|
},
|
|
430
|
+
"hosts": {
|
|
431
|
+
"type": "string",
|
|
432
|
+
"enum": ["auto", "both", "cursor", "claude_code"],
|
|
433
|
+
"default": "auto",
|
|
434
|
+
"description": (
|
|
435
|
+
"`auto`: SKILLFORGE_MATERIALIZE_HOSTS if set (both|cursor|claude_code), else MCP clientInfo "
|
|
436
|
+
"name/title (cursor / claude), else both."
|
|
437
|
+
),
|
|
438
|
+
},
|
|
405
439
|
"merge": {
|
|
406
440
|
"type": "boolean",
|
|
407
441
|
"description": (
|
|
408
|
-
"If false and .cursor/rules/skillforge.mdc, "
|
|
409
|
-
".cursor/commands/skillforge.md, or "
|
|
410
|
-
"
|
|
442
|
+
"If false and a targeted file already exists (.cursor/rules/skillforge.mdc, "
|
|
443
|
+
".cursor/commands/skillforge.md, or .claude/commands/skillforge.md), skip overwriting it "
|
|
444
|
+
"for hosts that apply."
|
|
411
445
|
),
|
|
412
446
|
"default": True,
|
|
413
447
|
},
|
|
@@ -435,10 +469,88 @@ class MCPServer:
|
|
|
435
469
|
"description": "Same as route_skills: merge indexed project file chunks into context.",
|
|
436
470
|
"default": False,
|
|
437
471
|
},
|
|
472
|
+
"hosts": {
|
|
473
|
+
"type": "string",
|
|
474
|
+
"enum": ["auto", "both", "cursor", "claude_code"],
|
|
475
|
+
"default": "auto",
|
|
476
|
+
"description": "Passed to materialize_project after route (same semantics as materialize_project).",
|
|
477
|
+
},
|
|
438
478
|
},
|
|
439
479
|
"required": ["prompt", "project_root"],
|
|
440
480
|
},
|
|
441
481
|
},
|
|
482
|
+
{
|
|
483
|
+
"name": "capabilities",
|
|
484
|
+
"description": (
|
|
485
|
+
"Session bootstrap: MCP response schema version, package semver from package.json (or env override), "
|
|
486
|
+
"ordered MCP tool names, progressive loading hints (`get_skill` formats), manifest/replay/`user_env_profile` "
|
|
487
|
+
"CLI pointers (~/.skillforge/env `config path|init|validate`), and router_snapshot matching get_router_status. Read-only."
|
|
488
|
+
),
|
|
489
|
+
"inputSchema": {"type": "object", "properties": {}},
|
|
490
|
+
},
|
|
491
|
+
{
|
|
492
|
+
"name": "get_router_status",
|
|
493
|
+
"description": (
|
|
494
|
+
"Read-only: effective SKILLFORGE_ROUTER_MODE, hybrid + rerank toggles from env, embed/router "
|
|
495
|
+
"model ids, shortlist/active caps, and whether Anthropic routing is wired (depends on MCP "
|
|
496
|
+
"process env)."
|
|
497
|
+
),
|
|
498
|
+
"inputSchema": {"type": "object", "properties": {}},
|
|
499
|
+
},
|
|
500
|
+
{
|
|
501
|
+
"name": "project_index_status",
|
|
502
|
+
"description": (
|
|
503
|
+
"Read-only: counts and last index metadata from the project orchestrator DB "
|
|
504
|
+
"(requires project_root or SKILLFORGE_PROJECT_ROOT). No network."
|
|
505
|
+
),
|
|
506
|
+
"inputSchema": {
|
|
507
|
+
"type": "object",
|
|
508
|
+
"properties": {
|
|
509
|
+
"project_root": {
|
|
510
|
+
"type": "string",
|
|
511
|
+
"description": "Workspace root that owns .skillforge/orchestrator.db",
|
|
512
|
+
},
|
|
513
|
+
},
|
|
514
|
+
},
|
|
515
|
+
},
|
|
516
|
+
{
|
|
517
|
+
"name": "weights_snapshot",
|
|
518
|
+
"description": (
|
|
519
|
+
"Read-only: export learned skill_weights for this user_id + DB (same shape as "
|
|
520
|
+
"`skillforge weights export`). Optional project_root / user_id."
|
|
521
|
+
),
|
|
522
|
+
"inputSchema": {
|
|
523
|
+
"type": "object",
|
|
524
|
+
"properties": {
|
|
525
|
+
"project_root": {"type": "string"},
|
|
526
|
+
"user_id": {"type": "string"},
|
|
527
|
+
},
|
|
528
|
+
},
|
|
529
|
+
},
|
|
530
|
+
{
|
|
531
|
+
"name": "events_recent",
|
|
532
|
+
"description": (
|
|
533
|
+
"Read-only: recent SQLite events (route, host_shortlist, feedback, …) for user_id, "
|
|
534
|
+
"newest first. Markdown lists at most 150 rows; `_meta.rows` JSON payloads cap at 100. "
|
|
535
|
+
"Optional event_type filter."
|
|
536
|
+
),
|
|
537
|
+
"inputSchema": {
|
|
538
|
+
"type": "object",
|
|
539
|
+
"properties": {
|
|
540
|
+
"project_root": {"type": "string"},
|
|
541
|
+
"user_id": {"type": "string"},
|
|
542
|
+
"limit": {
|
|
543
|
+
"type": "integer",
|
|
544
|
+
"description": "Max rows fetched from SQLite (default 25, max 500). Preview/Meta caps apply separately.",
|
|
545
|
+
"default": 25,
|
|
546
|
+
},
|
|
547
|
+
"event_type": {
|
|
548
|
+
"type": "string",
|
|
549
|
+
"description": "Optional exact event_type filter (e.g. route, host_shortlist)",
|
|
550
|
+
},
|
|
551
|
+
},
|
|
552
|
+
},
|
|
553
|
+
},
|
|
442
554
|
]
|
|
443
555
|
}
|
|
444
556
|
|
|
@@ -466,6 +578,16 @@ class MCPServer:
|
|
|
466
578
|
return self._tool_materialize_project(args)
|
|
467
579
|
if name == "skillforge_bootstrap":
|
|
468
580
|
return await self._tool_skillforge_bootstrap(args)
|
|
581
|
+
if name == "capabilities":
|
|
582
|
+
return self._tool_capabilities(args)
|
|
583
|
+
if name == "get_router_status":
|
|
584
|
+
return self._tool_get_router_status(args)
|
|
585
|
+
if name == "project_index_status":
|
|
586
|
+
return self._tool_project_index_status(args)
|
|
587
|
+
if name == "weights_snapshot":
|
|
588
|
+
return self._tool_weights_snapshot(args)
|
|
589
|
+
if name == "events_recent":
|
|
590
|
+
return self._tool_events_recent(args)
|
|
469
591
|
raise ValueError(f"Unknown tool: {name}")
|
|
470
592
|
|
|
471
593
|
async def _tool_route_skills(self, args):
|
|
@@ -643,101 +765,26 @@ class MCPServer:
|
|
|
643
765
|
limit = TOP_K_CANDIDATES
|
|
644
766
|
limit = max(1, min(limit, 50))
|
|
645
767
|
con = self._get_con(args)
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
exclude_skills, routing_boosts, project_notes = parse_routing_overlay(
|
|
649
|
-
policies_cfg,
|
|
650
|
-
by_name=self.router._by_name,
|
|
651
|
-
audit_out=overlay_audit,
|
|
652
|
-
)
|
|
653
|
-
route_query = merge_project_notes_into_route_query(
|
|
654
|
-
build_route_query_text(prompt, conversation),
|
|
655
|
-
project_notes,
|
|
656
|
-
pr,
|
|
657
|
-
)
|
|
658
|
-
facets = self.router.shortlist_with_facets(
|
|
659
|
-
route_query,
|
|
768
|
+
body, explain = await compute_explain_route(
|
|
769
|
+
self.router,
|
|
660
770
|
con,
|
|
661
|
-
|
|
771
|
+
prompt=prompt,
|
|
772
|
+
conversation=conversation,
|
|
773
|
+
limit=limit,
|
|
662
774
|
user_id=user_id,
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
)
|
|
666
|
-
candidates = self.router.shortlist(
|
|
667
|
-
route_query,
|
|
668
|
-
con,
|
|
669
|
-
limit,
|
|
670
|
-
user_id,
|
|
671
|
-
exclude_skills=exclude_skills,
|
|
672
|
-
routing_boosts=routing_boosts,
|
|
673
|
-
)
|
|
674
|
-
candidates = await self.router.rerank_candidates_haiku(route_query, conversation, candidates)
|
|
675
|
-
picked, reasoning = await self.router.pick_final(
|
|
676
|
-
prompt, conversation, candidates, route_query=route_query
|
|
677
|
-
)
|
|
678
|
-
merged, policy_audit = merge_policy_includes(
|
|
679
|
-
prompt,
|
|
680
|
-
list(picked),
|
|
681
|
-
policies_cfg,
|
|
682
|
-
self.router._by_name,
|
|
683
|
-
con,
|
|
684
|
-
user_id,
|
|
685
|
-
max_active=MAX_ACTIVE_SKILLS,
|
|
686
|
-
)
|
|
687
|
-
router_mode = "full" if self.router.anthropic else "embedding-only"
|
|
688
|
-
notes_effective = bool(project_notes.strip() and (pr or "").strip())
|
|
689
|
-
routing_ov = build_routing_overlay_payload(
|
|
690
|
-
project_root=pr or "",
|
|
691
|
-
exclude_skills=exclude_skills,
|
|
692
|
-
routing_boosts=routing_boosts,
|
|
693
|
-
project_notes_applied=notes_effective,
|
|
694
|
-
project_notes_len=len(project_notes) if project_notes else 0,
|
|
695
|
-
audit=overlay_audit,
|
|
775
|
+
project_root=pr,
|
|
776
|
+
db_path=db_path,
|
|
696
777
|
)
|
|
697
|
-
explain = {
|
|
698
|
-
"schema_version": MCP_RESPONSE_SCHEMA_VERSION,
|
|
699
|
-
"tool": "explain_route",
|
|
700
|
-
"orchestrator_db": redact_display_path(db_path) if redaction_enabled() else str(db_path),
|
|
701
|
-
"router_mode": router_mode,
|
|
702
|
-
"embedding_shortlist": facets,
|
|
703
|
-
"picked_before_policy": list(picked),
|
|
704
|
-
"picked_after_policy": merged,
|
|
705
|
-
"router_reasoning": reasoning,
|
|
706
|
-
"policy": {
|
|
707
|
-
"rules_loaded": len(policies_cfg.get("rules") or [])
|
|
708
|
-
if isinstance(policies_cfg.get("rules"), list)
|
|
709
|
-
else 0,
|
|
710
|
-
"audit": policy_audit,
|
|
711
|
-
},
|
|
712
|
-
}
|
|
713
|
-
if routing_ov is not None:
|
|
714
|
-
explain["routing_overlay"] = routing_ov
|
|
715
|
-
lines = [
|
|
716
|
-
"# explain_route — routing diagnostics (no DB writes)",
|
|
717
|
-
"",
|
|
718
|
-
f"**Router:** {router_mode}",
|
|
719
|
-
f"**Picked (router):** {', '.join(picked) if picked else '_(none)_'}",
|
|
720
|
-
f"**After policies:** {', '.join(merged) if merged else '_(none)_'}",
|
|
721
|
-
f"**Reasoning:** {reasoning}" if reasoning else "**Reasoning:** _(n/a)_",
|
|
722
|
-
"",
|
|
723
|
-
"## Shortlist (embedding)",
|
|
724
|
-
]
|
|
725
|
-
for f in facets[:15]:
|
|
726
|
-
lines.append(
|
|
727
|
-
f"- `{f['name']}` cos={f['cosine_similarity']} weight={f['learned_weight']} "
|
|
728
|
-
f"score={f['routing_score']}"
|
|
729
|
-
)
|
|
730
|
-
if policy_audit:
|
|
731
|
-
lines.extend(["", "## Policy audit"])
|
|
732
|
-
for row in policy_audit[:30]:
|
|
733
|
-
lines.append(f"- {row}")
|
|
734
|
-
body = "\n".join(lines)
|
|
735
778
|
return {"content": [{"type": "text", "text": body}], "_meta": explain}
|
|
736
779
|
|
|
737
780
|
def _tool_get_skill(self, args):
|
|
738
781
|
name = (args.get("skill_name") or "").strip()
|
|
739
|
-
|
|
740
|
-
if
|
|
782
|
+
fmt_raw = (args.get("format") or "full").strip().lower()
|
|
783
|
+
if fmt_raw in ("card", "minimal"):
|
|
784
|
+
fmt = "card"
|
|
785
|
+
elif fmt_raw == "summary":
|
|
786
|
+
fmt = "summary"
|
|
787
|
+
else:
|
|
741
788
|
fmt = "full"
|
|
742
789
|
max_chars = args.get("max_chars")
|
|
743
790
|
try:
|
|
@@ -750,7 +797,9 @@ class MCPServer:
|
|
|
750
797
|
"isError": True,
|
|
751
798
|
}
|
|
752
799
|
s = self.skills[name]
|
|
753
|
-
if fmt == "
|
|
800
|
+
if fmt == "card":
|
|
801
|
+
body = skill_routing_card(s)
|
|
802
|
+
elif fmt == "summary":
|
|
754
803
|
body = f"{s.description}\n\n---\n\n{(s.body or '')[:8000]}"
|
|
755
804
|
else:
|
|
756
805
|
body = s.body or ""
|
|
@@ -850,7 +899,20 @@ class MCPServer:
|
|
|
850
899
|
valid = [n for n in names_raw if isinstance(n, str) and n in self.skills]
|
|
851
900
|
desc = {n: self.skills[n].description for n in valid}
|
|
852
901
|
try:
|
|
853
|
-
|
|
902
|
+
mh = args.get("hosts")
|
|
903
|
+
if mh is None:
|
|
904
|
+
mh_arg: str | None = None
|
|
905
|
+
elif isinstance(mh, str):
|
|
906
|
+
mh_arg = mh.strip() or None
|
|
907
|
+
else:
|
|
908
|
+
mh_arg = str(mh).strip() or None
|
|
909
|
+
hosts_arg, hres = resolve_materialize_hosts_argument(
|
|
910
|
+
mh_arg,
|
|
911
|
+
client_name=getattr(self, "_mcp_client_name", ""),
|
|
912
|
+
client_title=getattr(self, "_mcp_client_title", ""),
|
|
913
|
+
)
|
|
914
|
+
out = materialize_project_files(root, valid, desc, merge=bool(merge), hosts=hosts_arg)
|
|
915
|
+
out.update(hres)
|
|
854
916
|
except ValueError as e:
|
|
855
917
|
return {"content": [{"type": "text", "text": str(e)}], "isError": True}
|
|
856
918
|
lines = ["# Skillforge — materialized project files", "", "Written:", *[f"- {p}" for p in out["written"]], ""]
|
|
@@ -865,8 +927,8 @@ class MCPServer:
|
|
|
865
927
|
merge = args.get("merge", True)
|
|
866
928
|
if SKILLFORGE_ROUTER_MODE == "host":
|
|
867
929
|
msg = (
|
|
868
|
-
"skillforge_bootstrap does not support SKILLFORGE_ROUTER_MODE=host
|
|
869
|
-
"Set SKILLFORGE_ROUTER_MODE=embedding for one-shot bootstrap, or call route_skills twice "
|
|
930
|
+
"skillforge_bootstrap does not support host routing (SKILLFORGE_ROUTER_MODE=host, the default). "
|
|
931
|
+
"Set SKILLFORGE_ROUTER_MODE=embedding or auto for one-shot bootstrap, or call route_skills twice "
|
|
870
932
|
"(shortlist then picked_names) and materialize_project yourself."
|
|
871
933
|
)
|
|
872
934
|
return {"content": [{"type": "text", "text": msg}], "isError": True}
|
|
@@ -891,7 +953,12 @@ class MCPServer:
|
|
|
891
953
|
return route
|
|
892
954
|
picked = (route.get("_meta") or {}).get("picked") or []
|
|
893
955
|
mat = self._tool_materialize_project(
|
|
894
|
-
{
|
|
956
|
+
{
|
|
957
|
+
"project_root": root,
|
|
958
|
+
"skill_names": picked,
|
|
959
|
+
"merge": merge,
|
|
960
|
+
"hosts": args.get("hosts"),
|
|
961
|
+
}
|
|
895
962
|
)
|
|
896
963
|
if mat.get("isError"):
|
|
897
964
|
return mat
|
|
@@ -908,6 +975,111 @@ class MCPServer:
|
|
|
908
975
|
},
|
|
909
976
|
}
|
|
910
977
|
|
|
978
|
+
def _tool_capabilities(self, args):
|
|
979
|
+
sc = len(self.skills) if self.skills else 0
|
|
980
|
+
bundle = build_capabilities_bundle(self.router, skill_count=sc)
|
|
981
|
+
text = format_capabilities_markdown(bundle)
|
|
982
|
+
db_path = resolve_orchestrator_db(self._project_root_from_args(args))
|
|
983
|
+
return {
|
|
984
|
+
"content": [{"type": "text", "text": text}],
|
|
985
|
+
"_meta": {
|
|
986
|
+
"tool": "capabilities",
|
|
987
|
+
"schema_version": MCP_RESPONSE_SCHEMA_VERSION,
|
|
988
|
+
"bundle": bundle,
|
|
989
|
+
"orchestrator_db": redact_display_path(db_path) if redaction_enabled() else str(db_path),
|
|
990
|
+
},
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
def _tool_get_router_status(self, args):
|
|
994
|
+
sc = len(self.skills) if self.skills else 0
|
|
995
|
+
snap = build_router_status_dict(self.router, skill_count=sc)
|
|
996
|
+
text = format_router_status_markdown(snap)
|
|
997
|
+
db_path = resolve_orchestrator_db(self._project_root_from_args(args))
|
|
998
|
+
return {
|
|
999
|
+
"content": [{"type": "text", "text": text}],
|
|
1000
|
+
"_meta": {
|
|
1001
|
+
"tool": "get_router_status",
|
|
1002
|
+
"schema_version": MCP_RESPONSE_SCHEMA_VERSION,
|
|
1003
|
+
"snapshot": snap,
|
|
1004
|
+
"orchestrator_db": redact_display_path(db_path) if redaction_enabled() else str(db_path),
|
|
1005
|
+
},
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
def _tool_project_index_status(self, args):
|
|
1009
|
+
pr = self._project_root_from_args(args)
|
|
1010
|
+
if not pr:
|
|
1011
|
+
return {
|
|
1012
|
+
"content": [{
|
|
1013
|
+
"type": "text",
|
|
1014
|
+
"text": "project_root argument or SKILLFORGE_PROJECT_ROOT is required.",
|
|
1015
|
+
}],
|
|
1016
|
+
"isError": True,
|
|
1017
|
+
}
|
|
1018
|
+
con = self._get_con(args)
|
|
1019
|
+
stats = project_index_status_dict(con)
|
|
1020
|
+
root_path = Path(pr).expanduser().resolve()
|
|
1021
|
+
text = format_project_index_markdown(stats, root_path)
|
|
1022
|
+
db_path = resolve_orchestrator_db(pr)
|
|
1023
|
+
return {
|
|
1024
|
+
"content": [{"type": "text", "text": text}],
|
|
1025
|
+
"_meta": {
|
|
1026
|
+
"tool": "project_index_status",
|
|
1027
|
+
"schema_version": MCP_RESPONSE_SCHEMA_VERSION,
|
|
1028
|
+
"project_root": str(root_path),
|
|
1029
|
+
"orchestrator_db": redact_display_path(db_path) if redaction_enabled() else str(db_path),
|
|
1030
|
+
**stats,
|
|
1031
|
+
},
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
def _tool_weights_snapshot(self, args):
|
|
1035
|
+
from app.weights_cli import export_weights
|
|
1036
|
+
|
|
1037
|
+
user_id = self._mcp_user_id(args)
|
|
1038
|
+
con = self._get_con(args)
|
|
1039
|
+
snap = export_weights(con, user_id)
|
|
1040
|
+
blob = json.dumps(snap, indent=2)
|
|
1041
|
+
db_path = resolve_orchestrator_db(self._project_root_from_args(args))
|
|
1042
|
+
return {
|
|
1043
|
+
"content": [{"type": "text", "text": f"# Skillforge — weights_snapshot\n\n```json\n{blob}\n```"}],
|
|
1044
|
+
"_meta": {
|
|
1045
|
+
"tool": "weights_snapshot",
|
|
1046
|
+
"schema_version": MCP_RESPONSE_SCHEMA_VERSION,
|
|
1047
|
+
"user_id": user_id,
|
|
1048
|
+
"row_count": len(snap.get("rows") or []),
|
|
1049
|
+
"orchestrator_db": redact_display_path(db_path) if redaction_enabled() else str(db_path),
|
|
1050
|
+
},
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
def _tool_events_recent(self, args):
|
|
1054
|
+
raw_lim = args.get("limit")
|
|
1055
|
+
try:
|
|
1056
|
+
limit = int(raw_lim) if raw_lim is not None else 25
|
|
1057
|
+
except (TypeError, ValueError):
|
|
1058
|
+
limit = 25
|
|
1059
|
+
et_raw = args.get("event_type")
|
|
1060
|
+
et = str(et_raw).strip() if isinstance(et_raw, str) and et_raw.strip() else None
|
|
1061
|
+
user_id = self._mcp_user_id(args)
|
|
1062
|
+
con = self._get_con(args)
|
|
1063
|
+
rows = events_recent_rows(con, limit=limit, user_id=user_id, event_type=et)
|
|
1064
|
+
text = format_events_markdown(rows)
|
|
1065
|
+
db_path = resolve_orchestrator_db(self._project_root_from_args(args))
|
|
1066
|
+
meta_cap = EVENTS_META_ROW_CAP
|
|
1067
|
+
meta_rows = rows[:meta_cap]
|
|
1068
|
+
return {
|
|
1069
|
+
"content": [{"type": "text", "text": text}],
|
|
1070
|
+
"_meta": {
|
|
1071
|
+
"tool": "events_recent",
|
|
1072
|
+
"schema_version": MCP_RESPONSE_SCHEMA_VERSION,
|
|
1073
|
+
"requested_limit": limit,
|
|
1074
|
+
"returned_count": len(rows),
|
|
1075
|
+
"user_id": user_id,
|
|
1076
|
+
"event_type_filter": et,
|
|
1077
|
+
"truncated_meta_rows": len(rows) > meta_cap,
|
|
1078
|
+
"rows": meta_rows,
|
|
1079
|
+
"orchestrator_db": redact_display_path(db_path) if redaction_enabled() else str(db_path),
|
|
1080
|
+
},
|
|
1081
|
+
}
|
|
1082
|
+
|
|
911
1083
|
# ---- JSON-RPC dispatcher ----
|
|
912
1084
|
|
|
913
1085
|
async def dispatch(self, request):
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Published npm semver for MCP ``serverInfo`` (keeps MCP in sync with ``package/package.json``)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
from functools import lru_cache
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
NPM_PACKAGE_NAME = "@heytherevibin/skillforge"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@lru_cache(maxsize=1)
|
|
13
|
+
def published_package_version() -> str:
|
|
14
|
+
"""Return ``version`` from the nearest ancestor ``package.json`` for this npm package.
|
|
15
|
+
|
|
16
|
+
Fallback ``0.0.0`` when the file tree does not contain the manifest (e.g. partial copy).
|
|
17
|
+
Optional override: ``SKILLFORGE_MCP_SERVER_VERSION`` for operators embedding a custom string.
|
|
18
|
+
"""
|
|
19
|
+
override = os.getenv("SKILLFORGE_MCP_SERVER_VERSION", "").strip()
|
|
20
|
+
if override:
|
|
21
|
+
return override
|
|
22
|
+
here = Path(__file__).resolve()
|
|
23
|
+
for d in here.parents:
|
|
24
|
+
pj = d / "package.json"
|
|
25
|
+
if not pj.is_file():
|
|
26
|
+
continue
|
|
27
|
+
try:
|
|
28
|
+
data = json.loads(pj.read_text(encoding="utf-8"))
|
|
29
|
+
except (OSError, json.JSONDecodeError, TypeError):
|
|
30
|
+
continue
|
|
31
|
+
if data.get("name") == NPM_PACKAGE_NAME:
|
|
32
|
+
v = str(data.get("version") or "").strip()
|
|
33
|
+
return v or "0.0.0"
|
|
34
|
+
return "0.0.0"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def clear_version_cache_for_tests() -> None:
|
|
38
|
+
published_package_version.cache_clear()
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Optional post-pick diversification (cap skills per source before policy merge)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
from typing import Any, Mapping
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _env_truthy_pick_diversify() -> bool:
|
|
9
|
+
return os.getenv("SKILLFORGE_PICK_DIVERSIFY", "").strip().lower() in ("1", "true", "yes")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def diversify_picked_names(
|
|
13
|
+
names: list[str],
|
|
14
|
+
by_name: Mapping[str, Any],
|
|
15
|
+
*,
|
|
16
|
+
max_per_source: int | None = None,
|
|
17
|
+
) -> tuple[list[str], dict[str, Any]]:
|
|
18
|
+
"""Drop excess picks from the same skill ``source`` while preserving order.
|
|
19
|
+
|
|
20
|
+
Returns ``(names_out, meta)``. When disabled, ``meta.applied`` is False and names are unchanged.
|
|
21
|
+
"""
|
|
22
|
+
meta: dict[str, Any] = {
|
|
23
|
+
"applied": False,
|
|
24
|
+
"dropped": [],
|
|
25
|
+
"max_per_source": max_per_source,
|
|
26
|
+
}
|
|
27
|
+
if not _env_truthy_pick_diversify():
|
|
28
|
+
return list(names), meta
|
|
29
|
+
|
|
30
|
+
mps = max_per_source
|
|
31
|
+
if mps is None:
|
|
32
|
+
raw = os.getenv("SKILLFORGE_PICK_MAX_PER_SOURCE", "2").strip()
|
|
33
|
+
try:
|
|
34
|
+
mps = int(raw)
|
|
35
|
+
except ValueError:
|
|
36
|
+
mps = 2
|
|
37
|
+
mps = max(1, int(mps))
|
|
38
|
+
meta["applied"] = True
|
|
39
|
+
meta["max_per_source"] = mps
|
|
40
|
+
|
|
41
|
+
counts: dict[str, int] = {}
|
|
42
|
+
out: list[str] = []
|
|
43
|
+
for n in names:
|
|
44
|
+
sk = by_name.get(n)
|
|
45
|
+
src = getattr(sk, "source", None) or "unknown"
|
|
46
|
+
if counts.get(src, 0) >= mps:
|
|
47
|
+
meta["dropped"].append(n)
|
|
48
|
+
continue
|
|
49
|
+
counts[src] = counts.get(src, 0) + 1
|
|
50
|
+
out.append(n)
|
|
51
|
+
return out, meta
|