This commit is contained in:
2026-04-14 00:16:59 +02:00
parent 5763f35220
commit 500b37bd26
20 changed files with 842 additions and 268 deletions

7
.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,7 @@
{
"recommendations": [
"ms-python.python",
"ms-python.vscode-pylance",
"ms-python.debugpy"
]
}

46
.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,46 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Python: pyMCPBroker (smoke config)",
"type": "debugpy",
"request": "launch",
"module": "pyMCPBroker",
"cwd": "${workspaceFolder}",
"args": [
"127.0.0.1:8100",
"smoke_config.json",
"--log-level",
"debug"
],
"console": "integratedTerminal",
"justMyCode": true
},
{
"name": "Python: pytest",
"type": "debugpy",
"request": "launch",
"module": "pytest",
"cwd": "${workspaceFolder}",
"args": [
"tests",
"-q"
],
"console": "integratedTerminal",
"justMyCode": false
},
{
"name": "Python: pytest current file",
"type": "debugpy",
"request": "launch",
"module": "pytest",
"cwd": "${workspaceFolder}",
"args": [
"${relativeFile}",
"-q"
],
"console": "integratedTerminal",
"justMyCode": false
}
]
}

18
.vscode/settings.json vendored
View File

@@ -1,7 +1,17 @@
{
"python.testing.pytestArgs": [
"tests"
],
"python.testing.pytestEnabled": true,
"python.testing.unittestEnabled": false,
"python.testing.pytestEnabled": true
"python.testing.pytestArgs": [
"tests",
"-q"
],
"python.testing.cwd": "${workspaceFolder}",
"python.analysis.extraPaths": [
"${workspaceFolder}"
],
"python.envFile": "${workspaceFolder}/.env",
"files.exclude": {
"**/__pycache__": true,
"**/.pytest_cache": true
}
}

37
.vscode/tasks.json vendored Normal file
View File

@@ -0,0 +1,37 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "Tests: pytest",
"type": "shell",
"command": "python -m pytest tests -q",
"options": {
"cwd": "${workspaceFolder}"
},
"group": {
"kind": "test",
"isDefault": true
},
"problemMatcher": []
},
{
"label": "Tests: current file",
"type": "shell",
"command": "python -m pytest ${relativeFile} -q",
"options": {
"cwd": "${workspaceFolder}"
},
"group": "test",
"problemMatcher": []
},
{
"label": "Run: broker (smoke config)",
"type": "shell",
"command": "python -m pyMCPBroker 127.0.0.1:8100 smoke_config.json --log-level info",
"options": {
"cwd": "${workspaceFolder}"
},
"problemMatcher": []
}
]
}

108
README.md
View File

@@ -1,31 +1,36 @@
# pyMCPBroker
Small FastAPI broker exposing three stable meta-tools over MCP backends:
Small FastAPI broker exposing three stable meta-tools over MCP `stdio` sources:
- `meta_tree`
- `meta_desc`
- `meta_call`
## Scope
The broker does not expose raw MCP tools directly. A model first discovers paths with `meta_tree`, inspects a leaf with `meta_desc`, then executes it with `meta_call`.
Current implementation:
## What is implemented
- MCP `stdio` backend only
- persistent subprocess per backend
- `initialize` + `notifications/initialized`
- paginated `tools/list`
- `tools/call`
- allow/deny wildcard filters
- backend overrides
- MCP `stdio` sources only
- persistent subprocess per unique source command
- `initialize`, `notifications/initialized`, `tools/list`, `tools/call`
- source-side tool auto-discovery
- optional allow/deny glob filters
- optional tool overrides
- optional path aliases
- broker-side JSON Schema validation
- compact structured error payloads
- result truncation with shape-preserving best effort
- optional shared secret (`Authorization: Bearer <secret>` or `X-Api-Key`)
- structured errors
- compact result truncation
- optional shared secret
## Install
```bash
pip install .
```
## Run
```bash
pip install .
python -m pyMCPBroker 0.0.0.0:8100 /config.json
```
@@ -35,4 +40,77 @@ Optional shared secret:
python -m pyMCPBroker 0.0.0.0:8100 /config.json mysecret
```
Example config: `examples/config.example.json`
## Config model
User-facing config is tree-first. You mount a real source inline on a node with `source`. The broker discovers its tools automatically and exposes them as child leaves of that node.
A declared root node is optional. If `tree` is a list, `/` is created implicitly.
Example:
```json
{
"tree": [
{
"path": "/repo",
"type": "node",
"summary": "Repository operations",
"children": [
{
"path": "/repo/read",
"type": "node",
"summary": "Read repository data",
"source": {
"backend": "stdio",
"command": "/opt/gitea-mcp/gitea-mcp --host ${GITEA_URL} --token ${GITEA_TOKEN}",
"tool_filter": ["get_*", "list_*", "search_*", "!delete_*", "!create_*"],
"path_aliases": {
"get_file_contents": "get_file"
},
"tool_overrides": {
"get_file_contents": {
"summary": "Read one file from a repository",
"max_output_chars": 12000,
"example_args": {
"owner": "myorg",
"repo": "demo-repo",
"ref": "main",
"filePath": "README.md"
}
}
}
}
}
]
}
]
}
```
With that config, the broker auto-exposes paths such as:
- `/repo/read/get_file`
- `/repo/read/list_branches`
- `/repo/read/search_code`
depending on what the mounted MCP source actually reports through `tools/list`.
## Notes
- `tool_filter` is optional. If omitted, all tools from the source are exposed.
- `tool_overrides` is optional.
- filter semantics are unordered:
- positive patterns allow
- `!pattern` denies
- if there is no positive pattern, all tools are allowed first, then deny rules are applied
- `path_aliases` only renames exposed leaf names; it does not change the real MCP tool name.
## Files
- `docs/SPEC.md`: user-facing spec for the implemented behavior
- `examples/config.example.json`: example config
## VS Code
The repository includes a minimal `.vscode/` setup for running and debugging pytest and for launching the broker with `smoke_config.json`.

235
docs/SPEC.md Normal file
View File

@@ -0,0 +1,235 @@
# pyMCPBroker Specification
## Purpose
pyMCPBroker exposes a very small stable API to a language model while wrapping one or more MCP servers running over `stdio`.
The model never sees raw MCP tools directly. It only sees three stable meta-tools:
- `meta_tree`
- `meta_desc`
- `meta_call`
Normal workflow:
1. call `meta_tree` to navigate
2. call `meta_desc` on a leaf path
3. call `meta_call` with arguments matching the schema returned by `meta_desc`
## Terminology
- **entry**: logical element exposed to the model
- `type=node`: navigation node
- `type=tool`: callable leaf
- **path**: stable absolute URL-like identifier
- **source**: real MCP backend mounted on a node
- `backend=stdio`: MCP process launched locally over stdin/stdout
## Public API
### `POST /meta_tree`
Input:
```json
{
"path": "/"
}
```
Returns the direct children of a node path.
Errors if the path does not exist or if it points to a tool leaf.
### `POST /meta_desc`
Input:
```json
{
"path": "/repo/read/get_file"
}
```
For a node path, returns node metadata and optionally summarized children.
For a tool path, returns:
- stable path
- summary
- description
- exact `args_schema`
- optional `example_args`
### `POST /meta_call`
Input:
```json
{
"path": "/repo/read/get_file",
"args": {
"owner": "myorg",
"repo": "demo-repo",
"ref": "main",
"filePath": "README.md"
}
}
```
The broker validates `args` against the dynamic schema previously returned by `meta_desc`, then calls the real MCP tool.
## Config format
The config is static JSON loaded at startup.
Top-level shape:
```json
{
"tree": [ ... ]
}
```
or:
```json
{
"tree": {
"path": "/",
"type": "node",
"children": [ ... ]
}
}
```
The explicit root node is optional. If omitted, `/` is created implicitly.
### Node fields
- `path`
- `type="node"`
- `summary` optional
- `description` optional
- `children` optional
- `source` optional
### Source fields
- `backend`: currently only `"stdio"`
- `command`: shell command to launch the MCP server
- `tool_filter`: optional unordered list of allow/deny glob patterns
- `tool_overrides`: optional per-tool overrides
- `path_aliases`: optional mapping from real MCP tool name to exposed leaf name
### Environment variables
`${ENV_VAR}` substitution is supported in strings, especially in `command`.
Missing variables fail at startup.
## Source mounting model
A `source` mounted on a node causes the broker to:
1. start the MCP process
2. initialize the MCP session
3. fetch `tools/list`
4. apply `tool_filter`
5. apply `tool_overrides`
6. expose the remaining tools as child leaves under the node path
The exposed leaf path is:
- `parent_path + / + alias`, if `path_aliases` defines one
- otherwise `parent_path + / + tool_name`
Example:
- node path: `/repo/read`
- real tool: `get_file_contents`
- alias: `get_file`
- exposed path: `/repo/read/get_file`
## Filter semantics
`tool_filter` is optional.
Positive patterns allow tools. Patterns prefixed with `!` deny tools.
Rules:
- if there is no positive pattern, all tools are allowed first, then deny rules are applied
- if at least one positive pattern exists, only tools matching a positive pattern are allowed, then deny rules are applied
- pattern order does not matter
Examples:
- `[]` → expose all tools
- `["!delete_*"]` → expose everything except delete tools
- `["get_*", "list_*"]` → expose only get/list tools
- `["get_*", "!get_secret_*"]` → expose get tools except secret ones
## Overrides
`tool_overrides` is optional.
Supported fields:
- `summary`
- `description`
- `max_output_chars`
- `timeout`
- `example_args`
- `render_mode`
They only affect the broker-facing presentation and execution limits. They do not rename the real MCP tool.
## Output normalization
The broker can truncate large outputs using `max_output_chars`.
Current behavior:
- preserve JSON structure when possible
- truncate long strings first
- compact long lists if needed
- return an explicit wrapper when truncation happened
## Internal MCP support
Current transport support:
- MCP `stdio` only
Required MCP methods:
- `initialize`
- `tools/list`
- `tools/call`
The broker also sends `notifications/initialized` after initialization.
## CLI
```bash
python -m pyMCPBroker 0.0.0.0:8100 /config.json
```
Optional shared secret:
```bash
python -m pyMCPBroker 0.0.0.0:8100 /config.json mysecret
```
Accepted options:
- `--reload`
- `--ignore-broken-tool`
- `--log-level`
- `--dump-tree`
## 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.

View File

@@ -1,9 +1,21 @@
{
"backends": {
"gitea": {
"tree": [
{
"path": "/repo",
"type": "node",
"summary": "Repository operations",
"children": [
{
"path": "/repo/read",
"type": "node",
"summary": "Read repository data",
"source": {
"backend": "stdio",
"command": "/opt/gitea-mcp/gitea-mcp --host ${GITEA_URL} --token ${GITEA_TOKEN}",
"tool_filter": ["get_*", "list_*", "search_*", "!delete_*", "!create_*"],
"path_aliases": {
"get_file_contents": "get_file"
},
"tool_overrides": {
"get_file_contents": {
"summary": "Read one file from a repository",
@@ -17,33 +29,8 @@
}
}
}
},
"tree": {
"path": "/",
"type": "node",
"summary": "Root",
"children": [
{
"path": "/repo",
"type": "node",
"summary": "Repository operations",
"children": [
{
"path": "/repo/read",
"type": "node",
"summary": "Read repository data",
"children": [
{
"path": "/repo/read/get_file",
"type": "tool",
"summary": "Read one file",
"backend_ref": "gitea",
"tool_name": "get_file_contents"
}
]
}
]
}
]
}
}

View File

@@ -1,2 +1,2 @@
__all__ = ["__version__"]
__version__ = "0.1.0"
__version__ = "0.2.1"

View File

@@ -5,6 +5,7 @@ from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from . import __version__
from .broker import Broker, BrokerError
from .models import MetaCallRequest, MetaDescRequest, MetaTreeRequest
@@ -20,9 +21,9 @@ def create_app(broker: Broker, shared_secret: str | None = None) -> FastAPI:
app = FastAPI(
title="pyMCPBroker",
version="0.1.0",
version=__version__,
description=(
"Expose three stable meta-tools over MCP backends. "
"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."
),
lifespan=lifespan,
@@ -40,13 +41,13 @@ def create_app(broker: Broker, shared_secret: str | None = None) -> FastAPI:
@app.exception_handler(BrokerError)
async def broker_error_handler(_: Request, exc: BrokerError) -> JSONResponse:
status = 400
if exc.error_code in {"backend_unavailable", "backend_call_failed", "missing_backend_tool"}:
if exc.error_code in {"backend_unavailable", "backend_call_failed", "empty_source"}:
status = 502
return JSONResponse(exc.as_payload(), status_code=status)
@app.get("/")
async def root() -> dict[str, object]:
return {"ok": True, "service": "pyMCPBroker", "meta_tools": ["meta_tree", "meta_desc", "meta_call"]}
return {"ok": True, "service": "pyMCPBroker", "version": __version__, "meta_tools": ["meta_tree", "meta_desc", "meta_call"]}
@app.post(
"/meta_tree",

View File

@@ -4,11 +4,12 @@ import json
import logging
import subprocess
import threading
import time
from dataclasses import dataclass, field
from queue import Queue
from typing import Any
from . import __version__
logger = logging.getLogger(__name__)
MCP_PROTOCOL_VERSION = "2025-06-18"
@@ -61,7 +62,7 @@ class MCPStdioBackend:
{
"protocolVersion": MCP_PROTOCOL_VERSION,
"capabilities": {},
"clientInfo": {"name": "pyMCPBroker", "version": "0.1.0"},
"clientInfo": {"name": "pyMCPBroker", "version": __version__},
},
timeout=10,
)
@@ -181,8 +182,4 @@ class MCPStdioBackend:
return tools
def call_tool(self, tool_name: str, arguments: dict[str, Any], timeout: float = 30) -> dict[str, Any]:
return self.request(
"tools/call",
params={"name": tool_name, "arguments": arguments},
timeout=timeout,
)
return self.request("tools/call", params={"name": tool_name, "arguments": arguments}, timeout=timeout)

View File

@@ -8,11 +8,9 @@ from jsonschema import Draft202012Validator
from jsonschema.exceptions import ValidationError
from .backend_stdio import MCPError, MCPStdioBackend
from .filters import is_allowed
from .models import BackendConfig, BackendOverride, NodeEntry, RootConfig, ToolEntry
from .overrides import apply_tool_overrides
from .models import NodeEntry, RootConfig, ToolEntry
from .render import normalize_result
from .tree import EntryIndex, TreeError, build_tree, normalize_path
from .tree import EntryIndex, SourceRegistry, TreeError, build_tree, normalize_path
logger = logging.getLogger(__name__)
@@ -34,93 +32,80 @@ class Broker:
def __init__(self, config: RootConfig, ignore_broken_tool: bool = False) -> None:
self.config = config
self.ignore_broken_tool = ignore_broken_tool
self.index: EntryIndex = build_tree(config.tree, set(config.backends))
self.index: EntryIndex | None = None
self.sources: SourceRegistry | None = None
self.backends: dict[str, MCPStdioBackend] = {}
self.tools_by_backend: dict[str, dict[str, dict[str, Any]]] = {}
self.raw_tools_by_backend: dict[str, list[dict[str, Any]]] = {}
self.broken_backends: dict[str, str] = {}
def startup(self) -> None:
for name, backend_cfg in self.config.backends.items():
provisional_index, sources = build_tree(self.config, {})
self.index = provisional_index
self.sources = sources
for key, source_cfg in sources.items():
try:
backend = self._start_backend(name, backend_cfg)
self.backends[name] = backend
backend = MCPStdioBackend(name=key, command=source_cfg.command)
backend.start()
self.backends[key] = backend
self.raw_tools_by_backend[key] = backend.list_tools(timeout=30)
except Exception as exc:
if not self.ignore_broken_tool:
raise
self.broken_backends[name] = str(exc)
logger.warning("Skipping broken backend %s: %s", name, exc)
self._validate_tree_links()
self.broken_backends[key] = str(exc)
logger.warning("Skipping broken backend %s: %s", key, exc)
self.raw_tools_by_backend[key] = []
self.index, self.sources = build_tree(self.config, self.raw_tools_by_backend)
if not self.ignore_broken_tool:
self._validate_all_sources_have_visible_tools()
def shutdown(self) -> None:
for backend in self.backends.values():
backend.close()
self.backends.clear()
self.raw_tools_by_backend.clear()
def _start_backend(self, name: str, backend_cfg: BackendConfig) -> MCPStdioBackend:
backend = MCPStdioBackend(name=name, command=backend_cfg.command)
backend.start()
raw_tools = backend.list_tools(timeout=30)
filtered: dict[str, dict[str, Any]] = {}
for tool in raw_tools:
tool_name = tool.get("name")
if not tool_name or not is_allowed(tool_name, backend_cfg.tool_filter):
continue
override = backend_cfg.tool_overrides.get(tool_name)
filtered[tool_name] = apply_tool_overrides(tool, override)
self.tools_by_backend[name] = filtered
return backend
def _validate_tree_links(self) -> None:
for path, entry in self.index.by_path.items():
if not isinstance(entry, ToolEntry):
continue
if entry.backend_ref in self.broken_backends:
continue
tools = self.tools_by_backend.get(entry.backend_ref, {})
if entry.tool_name not in tools:
raise BrokerError(
"missing_backend_tool",
f"Tree path {path} targets missing or filtered tool {entry.tool_name!r}",
)
def _validate_all_sources_have_visible_tools(self) -> None:
assert self.index is not None
for entry in self.index.by_path.values():
if isinstance(entry, NodeEntry) and entry.source is not None:
child_tools = [child for child in entry.children if isinstance(child, ToolEntry)]
if not child_tools:
raise BrokerError("empty_source", f"Node source exposes no tools: {entry.path}")
def _resolve_entry(self, path: str) -> NodeEntry | ToolEntry:
if self.index is None:
raise BrokerError("internal_error", "Broker not started")
try:
entry = self.index.get(path)
except TreeError as exc:
raise BrokerError("unknown_path", str(exc)) from exc
if isinstance(entry, NodeEntry) or isinstance(entry, ToolEntry):
if isinstance(entry, (NodeEntry, ToolEntry)):
return entry
raise BrokerError("internal_error", f"Unsupported entry type for {path}")
def _resolve_tool(self, path: str) -> tuple[ToolEntry, dict[str, Any], BackendConfig, MCPStdioBackend]:
def _resolve_tool(self, path: str) -> tuple[ToolEntry, MCPStdioBackend]:
entry = self._resolve_entry(path)
if not isinstance(entry, ToolEntry):
raise BrokerError("not_a_tool", f"Path is not a tool: {normalize_path(path)}")
if entry.backend_ref in self.broken_backends:
if entry.backend_key in self.broken_backends:
raise BrokerError(
"backend_unavailable",
f"Backend {entry.backend_ref!r} is unavailable: {self.broken_backends[entry.backend_ref]}",
f"Backend for {entry.path!r} is unavailable: {self.broken_backends[entry.backend_key]}",
)
tool = self.tools_by_backend.get(entry.backend_ref, {}).get(entry.tool_name)
backend_cfg = self.config.backends[entry.backend_ref]
backend = self.backends[entry.backend_ref]
if tool is None:
raise BrokerError("missing_backend_tool", f"Tool is not available: {entry.tool_name}")
return entry, tool, backend_cfg, backend
backend = self.backends.get(entry.backend_key)
if backend is None:
raise BrokerError("backend_unavailable", f"Backend is not available for path: {entry.path}")
return entry, backend
def meta_tree(self, path: str) -> dict[str, Any]:
entry = self._resolve_entry(path)
if not isinstance(entry, NodeEntry):
raise BrokerError("not_a_node", f"Path is not a node: {normalize_path(path)}")
children = [
{"path": child.path, "type": child.type, "summary": child.summary}
for child in entry.children
]
return {
"ok": True,
"path": entry.path,
"type": "node",
"children": children,
"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.",
}
@@ -136,53 +121,39 @@ class Broker:
"usage_hint": "Use meta_tree to navigate child paths.",
}
if entry.children:
payload["children"] = [
{"path": child.path, "type": child.type, "summary": child.summary}
for child in entry.children
]
payload["children"] = [{"path": child.path, "type": child.type, "summary": child.summary} for child in entry.children]
return payload
tool_entry, tool, _, _ = self._resolve_tool(path)
args_schema = tool.get("inputSchema") or {"type": "object", "properties": {}}
summary = tool.get("_broker_summary") or tool_entry.summary or tool.get("title") or tool_entry.tool_name
description = tool_entry.description or tool.get("description", "")
args_schema = entry.tool_meta.get("inputSchema") or {"type": "object", "properties": {}}
payload = {
"ok": True,
"path": tool_entry.path,
"path": entry.path,
"type": "tool",
"summary": summary,
"description": description,
"summary": entry.summary,
"description": entry.description,
"args_schema": args_schema,
"usage_hint": "Call meta_call with this path and args matching args_schema.",
}
example_args = tool.get("_broker_example_args")
example_args = entry.tool_meta.get("_broker_example_args")
if example_args is not None:
payload["example_args"] = example_args
return payload
def meta_call(self, path: str, args: dict[str, Any]) -> dict[str, Any]:
tool_entry, tool, _, backend = self._resolve_tool(path)
schema = tool.get("inputSchema") or {"type": "object", "properties": {}}
entry, backend = self._resolve_tool(path)
schema = entry.tool_meta.get("inputSchema") or {"type": "object", "properties": {}}
try:
Draft202012Validator(schema).validate(args)
except ValidationError as exc:
raise BrokerError(
"invalid_arguments",
exc.message,
{
"path": tool_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": "Call meta_desc on the same path before retrying."},
) from exc
timeout = float(tool.get("_broker_timeout") or 30)
max_output_chars = tool.get("_broker_max_output_chars")
timeout = float(entry.tool_meta.get("_broker_timeout") or 30)
max_output_chars = entry.tool_meta.get("_broker_max_output_chars")
try:
result = backend.call_tool(tool_entry.tool_name, args, timeout=timeout)
result = backend.call_tool(entry.tool_name, args, timeout=timeout)
except MCPError as exc:
raise BrokerError("backend_call_failed", str(exc), {"path": tool_entry.path}) from exc
return {
"ok": True,
"path": tool_entry.path,
"result": normalize_result(result, max_output_chars=max_output_chars),
}
raise BrokerError("backend_call_failed", str(exc), {"path": entry.path}) from exc
return {"ok": True, "path": entry.path, "result": normalize_result(result, max_output_chars=max_output_chars)}

View File

@@ -3,7 +3,6 @@ from __future__ import annotations
import argparse
import json
import logging
from pathlib import Path
import uvicorn
@@ -12,21 +11,18 @@ from .broker import Broker
from .config import load_config
def _parse_bind(value: str) -> tuple[str, int]:
if ":" not in value:
raise argparse.ArgumentTypeError("Bind address must be HOST:PORT")
host, port = value.rsplit(":", 1)
if not host:
raise argparse.ArgumentTypeError("Missing host")
def _parse_bind(bind: str) -> tuple[str, int]:
host, sep, port = bind.rpartition(":")
if not sep or not host or not port:
raise SystemExit(f"Invalid bind address: {bind!r}. Expected HOST:PORT")
return host, int(port)
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(prog="pyMCPBroker")
parser.add_argument("bind")
parser.add_argument("config_path")
parser.add_argument("shared_secret", nargs="?")
parser = argparse.ArgumentParser(prog="python -m pyMCPBroker")
parser.add_argument("bind", help="Bind host and port, format HOST:PORT")
parser.add_argument("config_path", help="Path to config JSON")
parser.add_argument("shared_secret", nargs="?", default=None, help="Optional shared secret")
parser.add_argument("--reload", action="store_true")
parser.add_argument("--ignore-broken-tool", action="store_true")
parser.add_argument("--log-level", default="info")
@@ -42,7 +38,18 @@ def main(argv: list[str] | None = None) -> int:
config = load_config(args.config_path)
broker = Broker(config, ignore_broken_tool=args.ignore_broken_tool)
if args.dump_tree:
print(json.dumps(config.tree, indent=2, ensure_ascii=False))
broker.startup()
try:
assert broker.index is not None
print(
json.dumps(
broker.meta_tree("/"),
indent=2,
ensure_ascii=False,
)
)
finally:
broker.shutdown()
return 0
app = create_app(broker, shared_secret=args.shared_secret)
uvicorn.run(app, host=host, port=port, reload=args.reload, log_level=str(args.log_level).lower())

View File

@@ -3,7 +3,7 @@ from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Literal
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, model_validator
class MetaTreeRequest(BaseModel):
@@ -19,7 +19,7 @@ class MetaCallRequest(BaseModel):
args: dict[str, Any] = Field(default_factory=dict)
class BackendOverride(BaseModel):
class ToolOverride(BaseModel):
summary: str | None = None
description: str | None = None
max_output_chars: int | None = None
@@ -28,16 +28,37 @@ class BackendOverride(BaseModel):
render_mode: str | None = None
class BackendConfig(BaseModel):
class SourceConfig(BaseModel):
backend: Literal["stdio"]
command: str
tool_filter: list[str] = Field(default_factory=list)
tool_overrides: dict[str, BackendOverride] = Field(default_factory=dict)
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":
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}")
return self
class NodeConfig(BaseModel):
path: str
type: Literal["node"] = "node"
summary: str = ""
description: str = ""
children: list["NodeConfig"] = Field(default_factory=list)
source: SourceConfig | None = None
NodeConfig.model_rebuild()
class RootConfig(BaseModel):
backends: dict[str, BackendConfig]
tree: dict[str, Any]
tree: NodeConfig | list[NodeConfig]
@dataclass(slots=True)
@@ -51,9 +72,11 @@ class Entry:
@dataclass(slots=True)
class NodeEntry(Entry):
children: list[Entry] = field(default_factory=list)
source: SourceConfig | None = None
@dataclass(slots=True)
class ToolEntry(Entry):
backend_ref: str = ""
backend_key: str = ""
tool_name: str = ""
tool_meta: dict[str, Any] = field(default_factory=dict)

View File

@@ -2,23 +2,23 @@ from __future__ import annotations
from typing import Any
from .models import BackendOverride
from .models import ToolOverride
def apply_tool_overrides(tool: dict[str, Any], override: BackendOverride | None) -> dict[str, Any]:
def apply_tool_overrides(tool: dict[str, Any], override: ToolOverride | None) -> dict[str, Any]:
merged = dict(tool)
if not override:
if override is None:
return merged
if override.summary is not None:
merged["_broker_summary"] = override.summary
if override.description is not None:
merged["description"] = override.description
if override.max_output_chars is not None:
merged["_broker_max_output_chars"] = int(override.max_output_chars)
if override.timeout is not None:
merged["_broker_timeout"] = float(override.timeout)
if override.example_args is not None:
merged["_broker_example_args"] = override.example_args
if override.max_output_chars is not None:
merged["_broker_max_output_chars"] = override.max_output_chars
if override.timeout is not None:
merged["_broker_timeout"] = override.timeout
if override.render_mode is not None:
merged["_broker_render_mode"] = override.render_mode
return merged

View File

@@ -1,8 +1,12 @@
from __future__ import annotations
from typing import Any
import json
import hashlib
from typing import Any, Iterable
from .models import Entry, NodeEntry, ToolEntry
from .filters import is_allowed
from .models import Entry, NodeConfig, NodeEntry, RootConfig, SourceConfig, ToolEntry
from .overrides import apply_tool_overrides
class TreeError(ValueError):
@@ -22,6 +26,24 @@ class EntryIndex:
raise TreeError(f"Unknown path: {normalized}") from exc
class SourceRegistry:
def __init__(self) -> None:
self._by_key: dict[str, SourceConfig] = {}
def key_for(self, source: SourceConfig) -> str:
payload = json.dumps(
{"backend": source.backend, "command": source.command},
sort_keys=True,
separators=(",", ":"),
)
key = hashlib.sha1(payload.encode("utf-8")).hexdigest()[:12]
self._by_key.setdefault(key, source)
return key
def items(self) -> Iterable[tuple[str, SourceConfig]]:
return self._by_key.items()
def normalize_path(path: str) -> str:
if not path:
raise TreeError("Path must not be empty")
@@ -34,40 +56,81 @@ def normalize_path(path: str) -> str:
return path
def build_tree(raw: dict[str, Any], known_backends: set[str]) -> EntryIndex:
by_path: dict[str, Entry] = {}
def join_path(parent: str, child_name: str) -> str:
if not child_name or "/" in child_name:
raise TreeError(f"Invalid child path segment: {child_name!r}")
return f"/{child_name}" if parent == "/" else f"{parent}/{child_name}"
def parse(node: dict[str, Any]) -> Entry:
entry_type = node.get("type")
path = normalize_path(node["path"])
if path in by_path:
raise TreeError(f"Duplicate path: {path}")
summary = node.get("summary", "")
description = node.get("description", "")
if entry_type == "node":
entry = NodeEntry(path=path, type="node", summary=summary, description=description)
by_path[path] = entry
entry.children = [parse(child) for child in node.get("children", [])]
return entry
if entry_type == "tool":
backend_ref = node.get("backend_ref", "")
if backend_ref not in known_backends:
raise TreeError(f"Unknown backend_ref {backend_ref!r} for {path}")
entry = ToolEntry(
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()
def add_entry(entry: Entry) -> None:
if entry.path in by_path:
raise TreeError(f"Duplicate path: {entry.path}")
by_path[entry.path] = entry
if isinstance(config.tree, list):
root_cfg = NodeConfig(path="/", type="node", summary="Root", description="", children=config.tree)
else:
root_cfg = config.tree
if normalize_path(root_cfg.path) != "/":
raise TreeError("Explicit tree object must be a node at /")
root = NodeEntry(path="/", type="node", summary=root_cfg.summary or "Root", description=root_cfg.description, source=root_cfg.source)
add_entry(root)
def parse_node(node_cfg: NodeConfig) -> NodeEntry:
if node_cfg.type != "node":
raise TreeError(f"Invalid node type for {node_cfg.path}: {node_cfg.type!r}")
path = normalize_path(node_cfg.path)
node = NodeEntry(
path=path,
type="node",
summary=node_cfg.summary,
description=node_cfg.description,
source=node_cfg.source,
)
add_entry(node)
for child_cfg in node_cfg.children:
child = parse_node(child_cfg)
node.children.append(child)
if node_cfg.source:
node.children.extend(_generate_source_children(node, node_cfg.source))
return node
def _generate_source_children(parent: NodeEntry, source: SourceConfig) -> list[ToolEntry]:
backend_key = sources.key_for(source)
raw_tools = raw_tools_by_backend.get(backend_key, [])
entries: list[ToolEntry] = []
for tool in raw_tools:
tool_name = tool.get("name")
if not tool_name or not is_allowed(tool_name, source.tool_filter):
continue
alias = source.path_aliases.get(tool_name, tool_name)
child_path = join_path(parent.path, alias)
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
description = tool_meta.get("description", "")
entry = ToolEntry(
path=child_path,
type="tool",
summary=summary,
description=description,
backend_ref=backend_ref,
tool_name=node.get("tool_name", ""),
backend_key=backend_key,
tool_name=tool_name,
tool_meta=tool_meta,
)
if not entry.tool_name:
raise TreeError(f"Missing tool_name for {path}")
by_path[path] = entry
return entry
raise TreeError(f"Invalid entry type for {path}: {entry_type!r}")
add_entry(entry)
entries.append(entry)
return entries
root = parse(raw)
if not isinstance(root, NodeEntry) or root.path != "/":
raise TreeError("Tree root must be a node at /")
return EntryIndex(root=root, by_path=by_path)
for child_cfg in root_cfg.children:
root.children.append(parse_node(child_cfg))
if root_cfg.source:
root.children.extend(_generate_source_children(root, root_cfg.source))
return EntryIndex(root=root, by_path=by_path), sources

View File

@@ -4,8 +4,8 @@ build-backend = "setuptools.build_meta"
[project]
name = "pyMCPBroker"
version = "0.1.0"
description = "Small FastAPI MCP broker exposing stable meta-tools over stdio MCP backends"
version = "0.2.1"
description = "Small FastAPI MCP broker exposing stable meta-tools over stdio MCP sources"
readme = "README.md"
requires-python = ">=3.10"
dependencies = [

View File

@@ -1 +1,9 @@
{"backends": {"gitea": {"backend": "stdio", "command": "/opt/pyvenv/bin/python /mnt/data/pyMCPBroker_project/tests/fake_mcp_server.py", "tool_filter": ["get_*", "!delete_*"], "tool_overrides": {}}}, "tree": {"path": "/", "type": "node", "children": [{"path": "/repo", "type": "node", "children": [{"path": "/repo/read/get_file", "type": "tool", "backend_ref": "gitea", "tool_name": "get_file_contents"}]}]}}
{
"tree": [
{
"path": "/repo",
"type": "node",
"summary": "Repository operations"
}
]
}

View File

@@ -12,15 +12,26 @@ from pyMCPBroker.broker import Broker
from pyMCPBroker.config import load_config
def make_config(tmp_path: Path) -> Path:
server = Path(__file__).with_name("fake_mcp_server.py")
command = f"{shlex.quote(sys.executable)} {shlex.quote(str(server))}"
config = {
"backends": {
"gitea": {
"tree": [
{
"path": "/repo",
"type": "node",
"summary": "Repository operations",
"children": [
{
"path": "/repo/read",
"type": "node",
"summary": "Read repository data",
"source": {
"backend": "stdio",
"command": command,
"tool_filter": ["get_*", "!delete_*"],
"path_aliases": {"get_file_contents": "get_file"},
"tool_overrides": {
"get_file_contents": {
"summary": "Read one file from a repository",
@@ -33,36 +44,11 @@ def make_config(tmp_path: Path) -> Path:
},
}
},
}
},
"tree": {
"path": "/",
"type": "node",
"summary": "Root",
"children": [
{
"path": "/repo",
"type": "node",
"summary": "Repository operations",
"children": [
{
"path": "/repo/read",
"type": "node",
"summary": "Read repository data",
"children": [
{
"path": "/repo/read/get_file",
"type": "tool",
"summary": "Read one file",
"backend_ref": "gitea",
"tool_name": "get_file_contents",
}
],
}
],
}
],
},
]
}
path = tmp_path / "config.json"
path.write_text(json.dumps(config), encoding="utf-8")
@@ -79,6 +65,10 @@ def test_meta_end_to_end(tmp_path: Path) -> None:
assert r.status_code == 200
assert r.json()["children"][0]["path"] == "/repo"
r = client.post("/meta_tree", json={"path": "/repo/read"})
assert r.status_code == 200
assert r.json()["children"][0]["path"] == "/repo/read/get_file"
r = client.post("/meta_desc", json={"path": "/repo/read/get_file"})
body = r.json()
assert body["summary"] == "Read one file from a repository"
@@ -130,3 +120,60 @@ def test_secret_auth(tmp_path: Path) -> None:
with TestClient(app) as client:
assert client.post("/meta_tree", json={"path": "/"}).status_code == 401
assert client.post("/meta_tree", json={"path": "/"}, headers={"Authorization": "Bearer sekret"}).status_code == 200
def test_no_filter_exposes_all_tools(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},
}
]
}
),
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()
child_paths = {child["path"] for child in body["children"]}
assert "/repo/get_file_contents" in child_paths
assert "/repo/delete_file" in child_paths
def test_explicit_root_is_optional(tmp_path: Path) -> None:
path = tmp_path / "config.json"
path.write_text(
json.dumps(
{
"tree": {
"path": "/",
"type": "node",
"summary": "Configured root",
"children": [
{"path": "/repo", "type": "node", "summary": "Repository operations"}
],
}
}
),
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": "/"})
assert r.status_code == 200
assert r.json()["children"][0]["path"] == "/repo"

View File

@@ -11,13 +11,64 @@ from pyMCPBroker.config import load_config
def test_env_substitution(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("BROKER_CMD", "python fake.py")
path = tmp_path / "config.json"
path.write_text(json.dumps({"backends": {"x": {"backend": "stdio", "command": "${BROKER_CMD}"}}, "tree": {"path": "/", "type": "node", "children": []}}), encoding="utf-8")
path.write_text(
json.dumps(
{
"tree": [
{
"path": "/repo",
"type": "node",
"source": {"backend": "stdio", "command": "${BROKER_CMD}"},
}
]
}
),
encoding="utf-8",
)
cfg = load_config(path)
assert cfg.backends["x"].command == "python fake.py"
assert cfg.tree[0].source is not None
assert cfg.tree[0].source.command == "python fake.py"
def test_env_missing_raises(tmp_path: Path) -> None:
path = tmp_path / "config.json"
path.write_text(json.dumps({"backends": {"x": {"backend": "stdio", "command": "${MISSING_VAR}"}}, "tree": {"path": "/", "type": "node", "children": []}}), encoding="utf-8")
path.write_text(
json.dumps(
{
"tree": [
{
"path": "/repo",
"type": "node",
"source": {"backend": "stdio", "command": "${MISSING_VAR}"},
}
]
}
),
encoding="utf-8",
)
with pytest.raises(ValueError):
load_config(path)
def test_invalid_path_alias_raises(tmp_path: Path) -> None:
path = tmp_path / "config.json"
path.write_text(
json.dumps(
{
"tree": [
{
"path": "/repo",
"type": "node",
"source": {
"backend": "stdio",
"command": "echo test",
"path_aliases": {"get_file_contents": "bad/name"},
},
}
]
}
),
encoding="utf-8",
)
with pytest.raises(Exception):
load_config(path)

View File

@@ -8,3 +8,9 @@ def test_filter_allow_then_deny() -> None:
assert not is_allowed("get_secret_token", patterns)
assert not is_allowed("delete_file", patterns)
assert filter_names(["get_file", "get_secret_token", "delete_file"], patterns) == ["get_file"]
def test_filter_deny_only_starts_from_all_allowed() -> None:
patterns = ["!delete_*"]
assert is_allowed("get_file", patterns)
assert not is_allowed("delete_file", patterns)