Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@
- [ ] Multi-signal rename confidence — [#80](https://github.com/askmy-stack/tool-semantics/issues/80) **P1**
- [ ] Optional embedding layer — [#117](https://github.com/askmy-stack/tool-semantics/issues/117) **P2**
- [ ] Optional LLM semantic judge — [#81](https://github.com/askmy-stack/tool-semantics/issues/81) **P2**
- [ ] Semantic distance / clustering — [#82](https://github.com/askmy-stack/tool-semantics/issues/82) **P2**
- [x] Semantic distance / clustering — [#82](https://github.com/askmy-stack/tool-semantics/issues/82) **P2**

## Milestone 10 — Traces & workflows
- [ ] Trace schema + capture — [#83](https://github.com/askmy-stack/tool-semantics/issues/83) **P1**
Expand Down
48 changes: 48 additions & 0 deletions docs/semantic-distance.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# Semantic distance matrix and tool clustering (#82)

Help developers understand large MCP catalogs: pairwise similarity plus
SEARCH / WRITE / DESTRUCTIVE-style action families.

## Defaults

| Mode | Behavior |
| --- | --- |
| Deterministic (default) | Weighted token Jaccard (`diff._tool_similarity`) — same heuristic as rename detection |
| Embeddings (optional) | Blend token score with cosine similarity when an `EmbeddingProvider` is passed |
| N ≥ 500 | Pairwise matrix is **subsampled** unless `--allow-large` / `allow_large=True` |

Full pairwise cost is O(N²). For large catalogs prefer subsample or raise
`--max-tools`.

## Action families (heuristic)

| Family | Signals |
| --- | --- |
| `SEARCH` | `read_only` risk or name/description tokens like search/find/list/get |
| `WRITE` | `external_write` or create/update/write/send tokens |
| `DESTRUCTIVE` | `destructive` risk or delete/remove/drop tokens |
| `OTHER` | Everything else |

## CLI

```bash
tool-semantics capture examples/semantic/github_catalog.json -o snap.json
tool-semantics cluster snap.json --top 20
tool-semantics cluster snap.json --allow-large # N≥500 full matrix
```

## Library

```python
from tool_semantics.scanner import capture_manifest
from tool_semantics.semantic import compute_semantic_matrix, render_semantic_matrix_markdown

snap = capture_manifest("examples/semantic/github_catalog.json")
report = compute_semantic_matrix(snap)
print(render_semantic_matrix_markdown(report))
```

## Related

- Tool collision / confusability (#79)
- Optional embeddings (#117)
83 changes: 83 additions & 0 deletions examples/semantic/github_catalog.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
{
"protocol": "mcp-manifest-demo",
"serverName": "github-catalog-demo",
"serverVersion": "1.0.0",
"tools": [
{
"name": "search_issues",
"description": "Search open GitHub issues matching a query.",
"risk": "read_only",
"inputSchema": {
"type": "object",
"properties": {"query": {"type": "string"}},
"required": ["query"]
}
},
{
"name": "find_pull_requests",
"description": "Find pull requests matching a search query.",
"risk": "read_only",
"inputSchema": {
"type": "object",
"properties": {"query": {"type": "string"}},
"required": ["query"]
}
},
{
"name": "list_repositories",
"description": "List repositories for the authenticated user.",
"risk": "read_only",
"inputSchema": {"type": "object", "properties": {}}
},
{
"name": "create_issue",
"description": "Create a new GitHub issue.",
"risk": "external_write",
"inputSchema": {
"type": "object",
"properties": {"title": {"type": "string"}, "body": {"type": "string"}},
"required": ["title", "body"]
}
},
{
"name": "update_issue",
"description": "Update an existing GitHub issue.",
"risk": "external_write",
"inputSchema": {
"type": "object",
"properties": {"number": {"type": "integer"}, "title": {"type": "string"}},
"required": ["number"]
}
},
{
"name": "create_pull_request",
"description": "Create a pull request.",
"risk": "external_write",
"inputSchema": {
"type": "object",
"properties": {"title": {"type": "string"}, "head": {"type": "string"}},
"required": ["title", "head"]
}
},
{
"name": "delete_repository",
"description": "Permanently delete a repository.",
"risk": "destructive",
"inputSchema": {
"type": "object",
"properties": {"name": {"type": "string"}},
"required": ["name"]
}
},
{
"name": "remove_collaborator",
"description": "Remove a collaborator from a repository.",
"risk": "destructive",
"inputSchema": {
"type": "object",
"properties": {"user": {"type": "string"}},
"required": ["user"]
}
}
]
}
89 changes: 89 additions & 0 deletions src/tool_semantics/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -702,3 +702,92 @@ def compare(
markdown_output.write_text(render_markdown(report), encoding="utf-8")
if fails_policy:
raise typer.Exit(code=1)


@app.command("cluster")
def cluster_cmd(
snapshot: Annotated[
Path,
typer.Argument(help="Tool-Semantics snapshot JSON."),
],
top: Annotated[
int,
typer.Option("--top", help="How many top similar pairs to show."),
] = 20,
threshold: Annotated[
float,
typer.Option(
"--threshold",
help="Similarity threshold for pairs / clusters (0–1).",
),
] = 0.55,
allow_large: Annotated[
bool,
typer.Option(
"--allow-large",
help="Compute full pairwise matrix when N≥500 (expensive).",
),
] = False,
max_tools: Annotated[
int,
typer.Option(
"--max-tools",
help="Subsample size when N≥500 and --allow-large is not set.",
),
] = 250,
json_output: Annotated[
Path | None,
typer.Option("--json-output", help="Write JSON semantic matrix report."),
] = None,
markdown_output: Annotated[
Path | None,
typer.Option("--markdown-output", help="Write Markdown semantic report."),
] = None,
verbose: Annotated[
bool,
typer.Option("--verbose", "-v", help="Log cluster steps to stderr."),
] = False,
) -> None:
"""Surface semantic distance matrix highlights and action-family clusters (#82)."""
from tool_semantics.semantic import (
compute_semantic_matrix,
matrix_as_dict,
render_semantic_matrix_markdown,
)

_require_snapshot_file(snapshot, "Snapshot")
if top < 1:
console.print("[red]--top must be >= 1[/red]")
raise typer.Exit(code=2)
if not 0.0 <= threshold <= 1.0:
console.print("[red]--threshold must be in [0, 1][/red]")
raise typer.Exit(code=2)
if max_tools < 2:
console.print("[red]--max-tools must be >= 2[/red]")
raise typer.Exit(code=2)

try:
snap = read_snapshot(snapshot)
except (ManifestError, FileNotFoundError, ValueError, OSError) as exc:
console.print(f"[red]cluster load failed:[/red] {exc}")
raise typer.Exit(code=2) from exc

_log_verbose(verbose, f"tools={len(snap.tools)} top={top} allow_large={allow_large}")
report = compute_semantic_matrix(
snap,
top_k=top,
similar_threshold=threshold,
allow_large=allow_large,
max_tools=max_tools,
)
console.print(render_semantic_matrix_markdown(report))

if json_output is not None:
json_output.parent.mkdir(parents=True, exist_ok=True)
json_output.write_text(
json.dumps(matrix_as_dict(report), indent=2) + "\n",
encoding="utf-8",
)
if markdown_output is not None:
markdown_output.parent.mkdir(parents=True, exist_ok=True)
markdown_output.write_text(render_semantic_matrix_markdown(report), encoding="utf-8")
Loading