diff --git a/README.md b/README.md index 43783b5..ad592dd 100644 --- a/README.md +++ b/README.md @@ -114,3 +114,8 @@ depending on what the mounted MCP source actually reports through `tools/list`. ## VS Code The repository includes a minimal `.vscode/` setup for running and debugging pytest and for launching the broker with `smoke_config.json`. + + +## 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`. diff --git a/docs/SPEC.md b/docs/SPEC.md index 999a870..bbe8b56 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -233,3 +233,8 @@ Accepted options: ## Repository editor config The repository may include a `.vscode/` directory with recommended Python extensions plus launch/task settings for pytest and for starting the broker against `smoke_config.json`. This editor config is optional and does not affect runtime behavior. + + +## Agent-facing OpenAPI behavior + +The public OpenAPI descriptions are part of the protocol surface. They explicitly tell a model that business tools are hidden behind the broker tree, that `meta_tree` is the discovery entrypoint, that `meta_desc` returns the exact schema for a discovered tool path, and that `meta_call` must only be used with arguments matching that schema. diff --git a/pyMCPBroker/__init__.py b/pyMCPBroker/__init__.py index dea755d..714affc 100644 --- a/pyMCPBroker/__init__.py +++ b/pyMCPBroker/__init__.py @@ -1,2 +1,2 @@ -__all__ = ["__version__"] -__version__ = "0.2.1" +__all__ = ['__version__'] +__version__ = '0.2.2' diff --git a/pyMCPBroker/app.py b/pyMCPBroker/app.py index 3bdcca2..041413f 100644 --- a/pyMCPBroker/app.py +++ b/pyMCPBroker/app.py @@ -10,6 +10,36 @@ from .broker import Broker, BrokerError from .models import MetaCallRequest, MetaDescRequest, MetaTreeRequest +META_TREE_EXAMPLE = { + "basic": { + "summary": "Start at the root node", + "value": {"path": "/"}, + } +} + +META_DESC_EXAMPLE = { + "tool_path": { + "summary": "Inspect a discovered tool path", + "value": {"path": "/repo/read/get_file"}, + } +} + +META_CALL_EXAMPLE = { + "tool_call": { + "summary": "Call a discovered tool path", + "value": { + "path": "/repo/read/get_file", + "args": { + "owner": "myorg", + "repo": "demo-repo", + "ref": "main", + "filePath": "README.md", + }, + }, + } +} + + def create_app(broker: Broker, shared_secret: str | None = None) -> FastAPI: @asynccontextmanager async def lifespan(_: FastAPI): @@ -23,8 +53,11 @@ def create_app(broker: Broker, shared_secret: str | None = None) -> FastAPI: title="pyMCPBroker", version=__version__, description=( - "Expose three stable meta-tools over MCP stdio sources. " - "Use meta_tree to navigate paths, meta_desc to inspect a path, and meta_call to execute a tool path." + "This broker hides business tools behind three stable entry tools. " + "The model should not expect the real business tools to appear directly in OpenAPI. " + "Instead, call meta_tree first to discover available paths, then call meta_desc on a chosen path to inspect it. " + "For a tool path, meta_desc returns the exact argument schema required before execution. " + "Finally, call meta_call to execute the hidden business tool behind that path." ), lifespan=lifespan, ) @@ -35,7 +68,10 @@ def create_app(broker: Broker, shared_secret: str | None = None) -> FastAPI: auth = request.headers.get("authorization", "") api_key = request.headers.get("x-api-key", "") if auth != f"Bearer {shared_secret}" and api_key != shared_secret: - return JSONResponse({"ok": False, "error_code": "unauthorized", "message": "Missing or invalid secret"}, status_code=401) + return JSONResponse( + {"ok": False, "error_code": "unauthorized", "message": "Missing or invalid secret"}, + status_code=401, + ) return await call_next(request) @app.exception_handler(BrokerError) @@ -47,28 +83,52 @@ def create_app(broker: Broker, shared_secret: str | None = None) -> FastAPI: @app.get("/") async def root() -> dict[str, object]: - return {"ok": True, "service": "pyMCPBroker", "version": __version__, "meta_tools": ["meta_tree", "meta_desc", "meta_call"]} + return { + "ok": True, + "service": "pyMCPBroker", + "version": __version__, + "meta_tools": ["meta_tree", "meta_desc", "meta_call"], + "usage_hint": "Start with meta_tree on '/' to discover hidden tool paths.", + } @app.post( "/meta_tree", - summary="meta_tree", - description="Meta-tool. Use this first to discover available paths. Call it on a node path to list its direct children.", + summary="Discover tool paths", + description=( + "Entry tool for discovery. Use this first when you want capabilities from this broker. " + "It lists the direct children of a node path in the broker tree. " + "The returned children may be more nodes for navigation or callable tool paths as leaves. " + "The real business tools are hidden behind this tree and are not exposed directly in OpenAPI. " + "Typical workflow: call meta_tree on '/', navigate until you find a useful tool path, then call meta_desc on that path." + ), + openapi_extra={"requestBody": {"content": {"application/json": {"examples": META_TREE_EXAMPLE}}}}, ) async def meta_tree(request: MetaTreeRequest) -> dict[str, object]: return broker.meta_tree(request.path) @app.post( "/meta_desc", - summary="meta_desc", - description="Meta-tool. Use this to inspect a path. For a tool path, it returns the exact argument schema to use before calling it.", + summary="Inspect a discovered path", + description=( + "Entry tool for inspection. Use this on any path previously discovered with meta_tree. " + "For a node path, it explains that node and may summarize its children. " + "For a tool path, it returns the exact argument schema required before execution. " + "Call this before meta_call unless you already know the required arguments exactly." + ), + openapi_extra={"requestBody": {"content": {"application/json": {"examples": META_DESC_EXAMPLE}}}}, ) async def meta_desc(request: MetaDescRequest) -> dict[str, object]: return broker.meta_desc(request.path) @app.post( "/meta_call", - summary="meta_call", - description="Meta-tool. Calls a tool path. The args object must match the schema previously returned by meta_desc.", + summary="Call a discovered tool path", + description=( + "Entry tool for execution. Use this only on a tool path previously discovered with meta_tree. " + "The args object must match the exact args_schema previously returned by meta_desc for that same path. " + "This is how the model invokes the hidden business tool behind the selected broker path." + ), + openapi_extra={"requestBody": {"content": {"application/json": {"examples": META_CALL_EXAMPLE}}}}, ) async def meta_call(request: MetaCallRequest) -> dict[str, object]: return broker.meta_call(request.path, request.args) diff --git a/pyMCPBroker/broker.py b/pyMCPBroker/broker.py index a526d98..7151c5f 100644 --- a/pyMCPBroker/broker.py +++ b/pyMCPBroker/broker.py @@ -106,7 +106,7 @@ class Broker: "path": entry.path, "type": "node", "children": [{"path": child.path, "type": child.type, "summary": child.summary} for child in entry.children], - "usage_hint": "Use meta_desc on a leaf path before meta_call.", + "usage_hint": "This broker hides business tools behind paths. Start here, then inspect a useful child path with meta_desc.", } def meta_desc(self, path: str) -> dict[str, Any]: @@ -118,7 +118,7 @@ class Broker: "type": "node", "summary": entry.summary, "description": entry.description, - "usage_hint": "Use meta_tree to navigate child paths.", + "usage_hint": "This is a navigation node. Use meta_tree on this path to browse its direct children.", } if entry.children: payload["children"] = [{"path": child.path, "type": child.type, "summary": child.summary} for child in entry.children] @@ -132,7 +132,7 @@ class Broker: "summary": entry.summary, "description": entry.description, "args_schema": args_schema, - "usage_hint": "Call meta_call with this path and args matching args_schema.", + "usage_hint": "Do not guess arguments. Copy this tool path exactly and call meta_call with args matching args_schema.", } example_args = entry.tool_meta.get("_broker_example_args") if example_args is not None: @@ -148,7 +148,11 @@ class Broker: raise BrokerError( "invalid_arguments", exc.message, - {"path": entry.path, "required": list(schema.get("required", [])), "usage_hint": "Call meta_desc on the same path before retrying."}, + { + "path": entry.path, + "required": list(schema.get("required", [])), + "usage_hint": "Your args do not match the required schema for this hidden tool. Re-read meta_desc for the same path and retry.", + }, ) from exc timeout = float(entry.tool_meta.get("_broker_timeout") or 30) max_output_chars = entry.tool_meta.get("_broker_max_output_chars") diff --git a/pyMCPBroker/models.py b/pyMCPBroker/models.py index 985b83b..5553715 100644 --- a/pyMCPBroker/models.py +++ b/pyMCPBroker/models.py @@ -7,16 +7,42 @@ from pydantic import BaseModel, Field, model_validator class MetaTreeRequest(BaseModel): - path: str = "/" + path: str = Field( + default='/', + description=( + "Node path to browse inside the broker tree. Use '/' first to discover the top-level entries. " + "This must be a node path, not a tool path." + ), + examples=['/'], + ) class MetaDescRequest(BaseModel): - path: str + path: str = Field( + description=( + "Previously discovered broker path to inspect. This can be either a node path or a tool path. " + "Use this after meta_tree to understand what a path represents and, for a tool path, to get its exact argument schema." + ), + examples=['/repo/read/get_file'], + ) class MetaCallRequest(BaseModel): - path: str - args: dict[str, Any] = Field(default_factory=dict) + path: str = Field( + description=( + "Callable tool path previously discovered with meta_tree and inspected with meta_desc. " + "This must be a leaf tool path, not a node path." + ), + examples=['/repo/read/get_file'], + ) + args: dict[str, Any] = Field( + default_factory=dict, + description=( + "Arguments for the selected tool path. These arguments must match the args_schema returned by meta_desc for the same path. " + "Do not invent fields that are not present in that schema." + ), + examples=[{'owner': 'myorg', 'repo': 'demo-repo', 'ref': 'main', 'filePath': 'README.md'}], + ) class ToolOverride(BaseModel): @@ -29,28 +55,28 @@ class ToolOverride(BaseModel): class SourceConfig(BaseModel): - backend: Literal["stdio"] + backend: Literal['stdio'] command: str tool_filter: list[str] = Field(default_factory=list) tool_overrides: dict[str, ToolOverride] = Field(default_factory=dict) path_aliases: dict[str, str] = Field(default_factory=dict) - @model_validator(mode="after") - def validate_aliases(self) -> "SourceConfig": + @model_validator(mode='after') + def validate_aliases(self) -> 'SourceConfig': for tool_name, alias in self.path_aliases.items(): if not tool_name: - raise ValueError("path_aliases keys must not be empty") - if not alias or "/" in alias or alias in {".", ".."}: - raise ValueError(f"Invalid path alias for tool {tool_name!r}: {alias!r}") + raise ValueError('path_aliases keys must not be empty') + if not alias or '/' in alias or alias in {'.', '..'}: + raise ValueError(f'Invalid path alias for tool {tool_name!r}: {alias!r}') return self class NodeConfig(BaseModel): path: str - type: Literal["node"] = "node" - summary: str = "" - description: str = "" - children: list["NodeConfig"] = Field(default_factory=list) + type: Literal['node'] = 'node' + summary: str = '' + description: str = '' + children: list['NodeConfig'] = Field(default_factory=list) source: SourceConfig | None = None @@ -64,9 +90,9 @@ class RootConfig(BaseModel): @dataclass(slots=True) class Entry: path: str - type: Literal["node", "tool"] - summary: str = "" - description: str = "" + type: Literal['node', 'tool'] + summary: str = '' + description: str = '' @dataclass(slots=True) @@ -77,6 +103,6 @@ class NodeEntry(Entry): @dataclass(slots=True) class ToolEntry(Entry): - backend_key: str = "" - tool_name: str = "" + backend_key: str = '' + tool_name: str = '' tool_meta: dict[str, Any] = field(default_factory=dict) diff --git a/pyproject.toml b/pyproject.toml index 9280710..5284a98 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "pyMCPBroker" -version = "0.2.1" +version = "0.2.2" description = "Small FastAPI MCP broker exposing stable meta-tools over stdio MCP sources" readme = "README.md" requires-python = ">=3.10" diff --git a/tests/test_openapi_guidance.py b/tests/test_openapi_guidance.py new file mode 100644 index 0000000..aba17d6 --- /dev/null +++ b/tests/test_openapi_guidance.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +from fastapi.testclient import TestClient + +from pyMCPBroker.app import create_app +from pyMCPBroker.broker import Broker +from pyMCPBroker.config import load_config + +from test_broker_end_to_end import make_config + + +def test_openapi_descriptions_and_examples(tmp_path): + cfg = load_config(make_config(tmp_path)) + broker = Broker(cfg) + app = create_app(broker) + + with TestClient(app) as client: + schema = client.get('/openapi.json').json() + + info_desc = schema['info']['description'] + assert 'hides business tools' in info_desc + assert 'meta_tree first' in info_desc + + tree_post = schema['paths']['/meta_tree']['post'] + assert tree_post['summary'] == 'Discover tool paths' + assert 'not exposed directly in OpenAPI' in tree_post['description'] + tree_examples = tree_post['requestBody']['content']['application/json']['examples'] + assert tree_examples['basic']['value']['path'] == '/' + + desc_post = schema['paths']['/meta_desc']['post'] + assert desc_post['summary'] == 'Inspect a discovered path' + assert 'exact argument schema' in desc_post['description'] + + call_post = schema['paths']['/meta_call']['post'] + assert call_post['summary'] == 'Call a discovered tool path' + assert 'hidden business tool' in call_post['description'] + call_examples = call_post['requestBody']['content']['application/json']['examples'] + assert call_examples['tool_call']['value']['path'] == '/repo/read/get_file'