diff --git a/README.md b/README.md index ad592dd..8820f3c 100644 --- a/README.md +++ b/README.md @@ -119,3 +119,8 @@ The repository includes a minimal `.vscode/` setup for running and debugging pyt ## OpenAPI guidance for agents The FastAPI/OpenAPI descriptions are intentionally directive. The broker hides business tools behind three stable entry tools, so agents should start with `meta_tree`, inspect with `meta_desc`, then execute with `meta_call`. + + +## 0.2.3 + +- Prefer MCP tool descriptions over raw tool names when building child summaries in `meta_tree`. diff --git a/pyMCPBroker/__init__.py b/pyMCPBroker/__init__.py index 714affc..95c16b6 100644 --- a/pyMCPBroker/__init__.py +++ b/pyMCPBroker/__init__.py @@ -1,2 +1,2 @@ __all__ = ['__version__'] -__version__ = '0.2.2' +__version__ = '0.2.3' diff --git a/pyMCPBroker/tree.py b/pyMCPBroker/tree.py index 79bd355..728df6a 100644 --- a/pyMCPBroker/tree.py +++ b/pyMCPBroker/tree.py @@ -2,6 +2,7 @@ from __future__ import annotations import json import hashlib +import re from typing import Any, Iterable from .filters import is_allowed @@ -62,6 +63,33 @@ def join_path(parent: str, child_name: str) -> str: return f"/{child_name}" if parent == "/" else f"{parent}/{child_name}" +def _humanize_tool_name(tool_name: str) -> str: + text = tool_name.replace('_', ' ').strip() + if not text: + return tool_name + return text[:1].upper() + text[1:] + + +def _summarize_description(description: str, max_chars: int = 100) -> str: + text = re.sub(r"\s+", " ", (description or "").strip()) + if not text: + return "" + parts = re.split(r"(?<=[.!?])\s+", text, maxsplit=1) + summary = parts[0].strip() + if len(summary) > max_chars: + summary = summary[: max_chars - 1].rstrip() + "…" + return summary + + +def _tool_summary(tool_name: str, tool_meta: dict[str, Any]) -> str: + return ( + tool_meta.get('_broker_summary') + or tool_meta.get('title') + or _summarize_description(tool_meta.get('description', '')) + or _humanize_tool_name(tool_name) + ) + + def build_tree(config: RootConfig, raw_tools_by_backend: dict[str, list[dict[str, Any]]]) -> tuple[EntryIndex, SourceRegistry]: by_path: dict[str, Entry] = {} sources = SourceRegistry() @@ -113,7 +141,7 @@ def build_tree(config: RootConfig, raw_tools_by_backend: dict[str, list[dict[str if child_path in by_path: raise TreeError(f"Duplicate path: {child_path}") tool_meta = apply_tool_overrides(tool, source.tool_overrides.get(tool_name)) - summary = tool_meta.get("_broker_summary") or tool_meta.get("title") or tool_name + summary = _tool_summary(tool_name, tool_meta) description = tool_meta.get("description", "") entry = ToolEntry( path=child_path, diff --git a/pyproject.toml b/pyproject.toml index 5284a98..5521e77 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "pyMCPBroker" -version = "0.2.2" +version = "0.2.3" description = "Small FastAPI MCP broker exposing stable meta-tools over stdio MCP sources" readme = "README.md" requires-python = ">=3.10" diff --git a/tests/fake_mcp_server.py b/tests/fake_mcp_server.py index 22eaa62..7a614f7 100644 --- a/tests/fake_mcp_server.py +++ b/tests/fake_mcp_server.py @@ -21,6 +21,19 @@ TOOLS = [ "additionalProperties": False, }, }, + { + "name": "list_branches", + "description": "List all branches in a repository for a given owner and repo.", + "inputSchema": { + "type": "object", + "properties": { + "owner": {"type": "string"}, + "repo": {"type": "string"} + }, + "required": ["owner", "repo"], + "additionalProperties": False + } + }, { "name": "delete_file", "title": "Delete file", @@ -71,7 +84,7 @@ for line in sys.stdin: { "jsonrpc": "2.0", "id": msg_id, - "result": {"tools": [TOOLS[0]], "nextCursor": "page-2"}, + "result": {"tools": [TOOLS[0], TOOLS[1]], "nextCursor": "page-2"}, } ) else: @@ -79,7 +92,7 @@ for line in sys.stdin: { "jsonrpc": "2.0", "id": msg_id, - "result": {"tools": [TOOLS[1]]}, + "result": {"tools": [TOOLS[2]]}, } ) continue diff --git a/tests/test_broker_end_to_end.py b/tests/test_broker_end_to_end.py index 977bd3d..5123e35 100644 --- a/tests/test_broker_end_to_end.py +++ b/tests/test_broker_end_to_end.py @@ -30,7 +30,7 @@ def make_config(tmp_path: Path) -> Path: "source": { "backend": "stdio", "command": command, - "tool_filter": ["get_*", "!delete_*"], + "tool_filter": ["get_*", "list_*", "!delete_*"], "path_aliases": {"get_file_contents": "get_file"}, "tool_overrides": { "get_file_contents": { @@ -67,7 +67,11 @@ def test_meta_end_to_end(tmp_path: Path) -> None: r = client.post("/meta_tree", json={"path": "/repo/read"}) assert r.status_code == 200 - assert r.json()["children"][0]["path"] == "/repo/read/get_file" + body = r.json() + child_summaries = {child["path"]: child["summary"] for child in body["children"]} + assert "/repo/read/get_file" in child_summaries + assert "/repo/read/list_branches" in child_summaries + assert child_summaries["/repo/read/list_branches"] == "List all branches in a repository for a given owner and repo." r = client.post("/meta_desc", json={"path": "/repo/read/get_file"}) body = r.json() @@ -177,3 +181,37 @@ def test_explicit_root_is_optional(tmp_path: Path) -> None: r = client.post("/meta_tree", json={"path": "/"}) assert r.status_code == 200 assert r.json()["children"][0]["path"] == "/repo" + + +def test_summary_falls_back_to_description_when_title_is_missing(tmp_path: Path) -> None: + server = Path(__file__).with_name("fake_mcp_server.py") + command = f"{shlex.quote(sys.executable)} {shlex.quote(str(server))}" + path = tmp_path / "config.json" + path.write_text( + json.dumps( + { + "tree": [ + { + "path": "/repo", + "type": "node", + "source": { + "backend": "stdio", + "command": command, + "tool_filter": ["list_*"], + }, + } + ] + } + ), + encoding="utf-8", + ) + cfg = load_config(path) + broker = Broker(cfg) + app = create_app(broker) + + with TestClient(app) as client: + r = client.post("/meta_tree", json={"path": "/repo"}) + body = r.json() + assert r.status_code == 200 + assert body["children"][0]["path"] == "/repo/list_branches" + assert body["children"][0]["summary"] == "List all branches in a repository for a given owner and repo."