From 27f6244dd55528879a3eee524b03b641286a877f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 25 May 2026 14:55:32 +0000 Subject: [PATCH] Add OpenAI-compatible /v1/tools endpoints (backward-compatible) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New tool_registry.py: module-level dict shared between server.py and frontend/app.py; populated at startup, read at request time. - server.py: one-line addition in register_tool() to mirror every MCP tool registration into tool_registry. No other changes. - frontend/app.py: two new endpoints on port 8889 (existing UI port): GET /v1/tools — tool list in OpenAI function-calling schema POST /v1/tools/{tool_name}/invoke — invoke with {"arguments": {...}} Result normalisation handles str / list / dict-with-content / plain-dict. Handler exceptions surface as is_error:true (HTTP 200) so LLMs see them. - tests/test_server.py: add TestToolRegistry (register, get, get_all, overwrite, copy isolation, clear). - tests/test_openai_api.py: 25 new tests covering GET /v1/tools schema shape, parameter passthrough, all result-normalisation branches, 404 on unknown tool, error handling, and empty-body tolerance. - tests/test_mcp_client.sh: add MODE selector (1=MCP, 2=OpenAI) after Ollama model selection. MCP path unchanged; OpenAI path uses GET /v1/tools for listing and POST /v1/tools/{name}/invoke for calls. File-listing step (mcpproxy__listfiles/getfile) branches on mode too. MODE env var skips the menu (mirrors OLLAMA_MODEL pattern). All 232 tests pass (214 pre-existing + 18 new). https://claude.ai/code/session_01V9SNVHv8uhS8pejXW9Dt7m --- frontend/app.py | 115 ++++++++++++++ server.py | 2 + tests/test_mcp_client.sh | 328 ++++++++++++++++++++++++++++++++------- tests/test_openai_api.py | 272 ++++++++++++++++++++++++++++++++ tests/test_server.py | 65 ++++++++ tool_registry.py | 40 +++++ 6 files changed, 763 insertions(+), 59 deletions(-) create mode 100644 tests/test_openai_api.py create mode 100644 tool_registry.py diff --git a/frontend/app.py b/frontend/app.py index 03eb0e6..96af89c 100644 --- a/frontend/app.py +++ b/frontend/app.py @@ -477,6 +477,121 @@ async def set_env(request: Request) -> dict: _write_env_file(_env_file, updates) return {"ok": True, "written": list(updates.keys())} + # ── OpenAI-compatible tool endpoints ───────────────────────────────────── + # + # These endpoints let OpenAI-style callers (e.g. OpenWebUI tool servers) + # list and invoke the same tools exposed over MCP on port 8888 — without + # speaking the MCP protocol. They are a pure addition: the /mcp endpoint + # and all /api/* endpoints are completely unaffected. + # + # GET /v1/tools — list tools in OpenAI function-calling format + # POST /v1/tools/{tool_name}/invoke — call a tool with {"arguments": {...}} + + @app.get("/v1/tools") + async def list_openai_tools() -> dict: + """Return all registered tools in OpenAI function-calling schema format. + + Response shape:: + + { + "tools": [ + { + "type": "function", + "function": { + "name": "playwright__browser_navigate", + "description": "...", + "parameters": { } + } + }, + ... + ] + } + """ + import tool_registry + tools_out = [] + for name, entry in tool_registry.get_all().items(): + spec = entry["spec"] + input_schema = spec.get("input_schema") or { + "type": "object", + "properties": {}, + "required": [], + } + tools_out.append({ + "type": "function", + "function": { + "name": name, + "description": spec.get("description", ""), + "parameters": input_schema, + }, + }) + return {"tools": tools_out} + + @app.post("/v1/tools/{tool_name}/invoke") + async def invoke_openai_tool(tool_name: str, request: Request) -> dict: + """Invoke a registered tool by name with caller-supplied arguments. + + Request body:: + + {"arguments": {"param1": "value1", ...}} + + Success response:: + + { + "type": "tool_result", + "content": [{"type": "text", "text": "..."}], + "is_error": false + } + + Error response (tool not found → HTTP 404; handler exception → HTTP 200 with + ``is_error: true`` so that LLM callers can see the error message as a tool + result rather than receiving a 5xx):: + + { + "type": "tool_result", + "content": [{"type": "text", "text": ""}], + "is_error": true + } + """ + import tool_registry + entry = tool_registry.get(tool_name) + if entry is None: + raise HTTPException(404, f"Tool '{tool_name}' not found") + + try: + body = await request.json() + except Exception: + body = {} + arguments: dict[str, Any] = (body.get("arguments") if isinstance(body, dict) else None) or {} + handler = entry["handler"] + + try: + # dynamic_tool signature: (ctx: Context, **kwargs). + # Passing ctx=None is safe — build_runtime_context stores it as + # {"mcp_context": None} and tool handlers that don't use MCP + # context features won't notice. + result = await handler(None, **arguments) + + # Normalise the result to a content array so callers always get a + # consistent shape regardless of what the underlying handler returns. + if isinstance(result, str): + content = [{"type": "text", "text": result}] + elif isinstance(result, dict) and "content" in result: + content = result["content"] + elif isinstance(result, list): + content = result + else: + content = [{"type": "text", "text": json.dumps(result, ensure_ascii=False)}] + + return {"type": "tool_result", "content": content, "is_error": False} + + except Exception as exc: + traceback.print_exc() + return { + "type": "tool_result", + "content": [{"type": "text", "text": str(exc)}], + "is_error": True, + } + # ── Restart ─────────────────────────────────────────────────────────────── @app.post("/api/restart") diff --git a/server.py b/server.py index 9e2c6f0..a8050be 100755 --- a/server.py +++ b/server.py @@ -258,6 +258,8 @@ async def dynamic_tool(ctx: Context, **kwargs: Any) -> Any: dynamic_tool.__annotations__ = annotations mcp.tool(name=exposed_name, description=tool_spec.get("description", ""))(dynamic_tool) + from tool_registry import register as _tool_registry_register + _tool_registry_register(exposed_name, tool_spec, dynamic_tool) print(f"Registered tool: {exposed_name}") except Exception as exc: print(f"register_tool error for '{tool_spec.get('name')}': {exc}") diff --git a/tests/test_mcp_client.sh b/tests/test_mcp_client.sh index c5f9c2e..bf868bd 100755 --- a/tests/test_mcp_client.sh +++ b/tests/test_mcp_client.sh @@ -1,22 +1,29 @@ #!/usr/bin/env bash -# tests/test_mcp_client.sh — Generic interactive MCP tool tester + Ollama summary +# tests/test_mcp_client.sh — Interactive MCP / OpenAI-compatible tool tester + Ollama summary # # Flow # ──── # 1. Pick an Ollama model (menu or $OLLAMA_MODEL). -# 2. Initialize an MCP session with mcpproxy. -# 3. Show every registered tool; let you pick one. -# 4. Check that required secrets are present in .env (never prints values). -# 5. Prompt for each non-secret parameter (type-aware, required vs optional). -# 6. Call the tool — secrets are injected server-side, never by this script. -# 7. Display the result and ask Ollama to summarise it. +# 2. Choose protocol mode: +# MCP — JSON-RPC via /mcp on port 8888 (default, existing behaviour) +# OpenAI — REST via /v1/tools on port 8889 (new OpenAI-compatible endpoints) +# 3. [MCP] Initialize an MCP session with mcpproxy. +# [OpenAI] GET /v1/tools to retrieve the tool list. +# 4. Show every registered tool; let you pick one. +# 5. Check that required secrets are present in .env (never prints values). +# 6. Prompt for each non-secret parameter (type-aware, required vs optional). +# 7. Call the tool: +# MCP — tools/call JSON-RPC request to port 8888 +# OpenAI — POST /v1/tools/{name}/invoke to port 8889 +# 8. Display the result and ask Ollama to summarise it. # # Environment overrides: # MCP_URL [http://localhost:8888/mcp] -# UI_URL [http://localhost:8889] used to look up secret metadata +# UI_URL [http://localhost:8889] OpenAI endpoints + secret metadata # OLLAMA_URL [http://localhost:11434] # OLLAMA_MODEL skip model menu -# ENV_FILE [.env] checked for secret presence only +# MODE 1=MCP 2=OpenAI (skip mode menu) +# ENV_FILE [.env] checked for secret presence only set -euo pipefail MCP_URL="${MCP_URL:-http://localhost:8888/mcp}" @@ -190,16 +197,46 @@ if [[ -z "${OLLAMA_MODEL:-}" ]]; then fi fi -# ── Step 3: MCP initialize ──────────────────────────────────────────────────── +# ── Step 3: Mode selection ──────────────────────────────────────────────────── sep -printf ' Checking MCP server… ' +printf ' Protocol mode:\n' +printf ' 1) MCP — JSON-RPC via /mcp on port 8888 (full MCP protocol)\n' +printf ' 2) OpenAI — REST via /v1/tools on port 8889 (OpenAI-compatible)\n' -INIT_REQ="${TMP_DIR}/init_req.json" -INIT_RESP="${TMP_DIR}/init_resp.json" -HEADERS_FILE="${TMP_DIR}/headers.txt" +if [[ -n "${MODE:-}" ]]; then + if [[ "${MODE}" == "1" || "${MODE}" == "2" ]]; then + printf '\n Mode (env): %s\n' "$( [[ "${MODE}" == "1" ]] && echo 'MCP' || echo 'OpenAI' )" + else + printf '\n ⚠ MODE="%s" not recognised — showing menu.\n' "${MODE}" + unset MODE + fi +fi + +if [[ -z "${MODE:-}" ]]; then + while true; do + printf '\n Select mode [1-2]: ' + read -r MODE + [[ "${MODE}" == "1" || "${MODE}" == "2" ]] && break + printf ' Invalid choice — enter 1 (MCP) or 2 (OpenAI).\n' + done +fi + +# ── Step 4: Connect to server + list tools ─────────────────────────────────── + +TOOLS_TSV="${TMP_DIR}/tools.tsv" +# Will be populated differently depending on MODE. + +if [[ "${MODE}" == "1" ]]; then + # ────────────────────────────────── MCP mode ────────────────────────────── + sep + printf ' Checking MCP server… ' + + INIT_REQ="${TMP_DIR}/init_req.json" + INIT_RESP="${TMP_DIR}/init_resp.json" + HEADERS_FILE="${TMP_DIR}/headers.txt" -cat > "${INIT_REQ}" <<'JSON' + cat > "${INIT_REQ}" <<'JSON' { "jsonrpc": "2.0", "id": 1, "method": "initialize", @@ -211,39 +248,37 @@ cat > "${INIT_REQ}" <<'JSON' } JSON -curl -fsS --max-time 10 \ - -H 'Content-Type: application/json' \ - -H 'Accept: application/json, text/event-stream' \ - --data "@${INIT_REQ}" \ - -D "${HEADERS_FILE}" \ - "${MCP_URL}" > "${INIT_RESP}" \ - || die "MCP server not reachable at ${MCP_URL}\n Start it: ./run_local.sh" -printf 'OK\n' - -_RPC_ID=1 -SESSION_ID="$(awk 'BEGIN{IGNORECASE=1} /^mcp-session-id:/ {gsub("\r","", $2); print $2}' \ - "${HEADERS_FILE}" | tail -n 1)" - -SESSION_HEADER_ARGS=( - -H 'Content-Type: application/json' - -H 'Accept: application/json, text/event-stream' -) -[[ -n "${SESSION_ID}" ]] && SESSION_HEADER_ARGS+=(-H "Mcp-Session-Id: ${SESSION_ID}") -printf ' Session: %s\n' "${SESSION_ID:-(stateless)}" - -# ── Step 4: tools/list + tool selection ────────────────────────────────────── - -sep -TOOLS_REQ="${TMP_DIR}/tools_req.json" -TOOLS_RESP="${TMP_DIR}/tools_resp.json" + curl -fsS --max-time 10 \ + -H 'Content-Type: application/json' \ + -H 'Accept: application/json, text/event-stream' \ + --data "@${INIT_REQ}" \ + -D "${HEADERS_FILE}" \ + "${MCP_URL}" > "${INIT_RESP}" \ + || die "MCP server not reachable at ${MCP_URL}\n Start it: ./run_local.sh" + printf 'OK\n' + + _RPC_ID=1 + SESSION_ID="$(awk 'BEGIN{IGNORECASE=1} /^mcp-session-id:/ {gsub("\r","", $2); print $2}' \ + "${HEADERS_FILE}" | tail -n 1)" + + SESSION_HEADER_ARGS=( + -H 'Content-Type: application/json' + -H 'Accept: application/json, text/event-stream' + ) + [[ -n "${SESSION_ID}" ]] && SESSION_HEADER_ARGS+=(-H "Mcp-Session-Id: ${SESSION_ID}") + printf ' Session: %s\n' "${SESSION_ID:-(stateless)}" + + # tools/list + sep + TOOLS_REQ="${TMP_DIR}/tools_req.json" + TOOLS_RESP="${TMP_DIR}/tools_resp.json" -printf '{"jsonrpc":"2.0","id":%d,"method":"tools/list","params":{}}\n' "$(next_id)" \ - > "${TOOLS_REQ}" -mcp_post "${TOOLS_REQ}" "${TOOLS_RESP}" + printf '{"jsonrpc":"2.0","id":%d,"method":"tools/list","params":{}}\n' "$(next_id)" \ + > "${TOOLS_REQ}" + mcp_post "${TOOLS_REQ}" "${TOOLS_RESP}" -# Write tool names + descriptions to a TSV; handles SSE or plain JSON. -TOOLS_TSV="${TMP_DIR}/tools.tsv" -python3 - "${TOOLS_RESP}" "${TOOLS_TSV}" <<'PY' + # Write tool names + descriptions to a TSV; handles SSE or plain JSON. + python3 - "${TOOLS_RESP}" "${TOOLS_TSV}" <<'PY' import json, sys, os from pathlib import Path @@ -261,6 +296,58 @@ with open(sys.argv[2], 'w', encoding='utf-8') as f: f.write(f"{name}\t{desc}\n") PY +else + # ────────────────────────────── OpenAI mode ─────────────────────────────── + sep + printf ' Checking OpenAI-compatible endpoints at %s… ' "${UI_URL}" + + OPENAI_TOOLS_RESP="${TMP_DIR}/openai_tools_resp.json" + curl -fsS --max-time 10 \ + -H 'Accept: application/json' \ + "${UI_URL}/v1/tools" > "${OPENAI_TOOLS_RESP}" \ + || die "UI server not reachable at ${UI_URL}\n Start it: ./run_local.sh" + printf 'OK\n' + + # Parse the OpenAI tools list into the same TSV format used by MCP mode. + # Also write a full JSON copy for use during parameter prompting. + TOOLS_RESP="${TMP_DIR}/tools_resp.json" # reused by param-prompting step + python3 - "${OPENAI_TOOLS_RESP}" "${TOOLS_TSV}" "${TOOLS_RESP}" <<'PY' +import json, sys +from pathlib import Path + +data = json.loads(Path(sys.argv[1]).read_text(encoding='utf-8')) +tools = data.get('tools', []) +tsv = Path(sys.argv[2]) +resp = Path(sys.argv[3]) + +if not tools: + print('no_tools', flush=True) + sys.exit(0) + +with tsv.open('w', encoding='utf-8') as f: + for t in tools: + fn = t.get('function') or {} + name = fn.get('name', '') + desc = fn.get('description', '').replace('\n', ' ').replace('\t', ' ')[:72] + f.write(f"{name}\t{desc}\n") + +# Produce a tools/list-shaped response so the param-prompting step (which +# already parses this format) works without modification. +mcp_tools = [] +for t in tools: + fn = t.get('function') or {} + mcp_tools.append({ + 'name': fn.get('name', ''), + 'description': fn.get('description', ''), + 'inputSchema': fn.get('parameters') or {}, + }) +resp.write_text( + json.dumps({'result': {'tools': mcp_tools}}, indent=2), + encoding='utf-8', +) +PY +fi # end MODE branch + [[ -s "${TOOLS_TSV}" ]] || die "No tools registered in mcpproxy." mapfile -t TOOL_NAMES < <(cut -f1 "${TOOLS_TSV}") @@ -538,11 +625,14 @@ except Exception as e: print(f' (could not read args: {e})', file=sys.stderr) " -CALL_REQ="${TMP_DIR}/call_req.json" CALL_RAW="${TMP_DIR}/call_raw.json" CALL_RESULT="${TMP_DIR}/call_result.json" -python3 - "${SELECTED_TOOL}" "${ARGS_FILE}" "${CALL_REQ}" "$(next_id)" <<'PY' +if [[ "${MODE}" == "1" ]]; then + # ──────────────────────────────── MCP call ────────────────────────────── + CALL_REQ="${TMP_DIR}/call_req.json" + + python3 - "${SELECTED_TOOL}" "${ARGS_FILE}" "${CALL_REQ}" "$(next_id)" <<'PY' import json, sys from pathlib import Path tool = sys.argv[1] @@ -556,8 +646,81 @@ req = {"jsonrpc": "2.0", "id": rid, "method": "tools/call", Path(sys.argv[3]).write_text(json.dumps(req, indent=2), encoding='utf-8') PY -mcp_post "${CALL_REQ}" "${CALL_RAW}" -extract_tool_result "${CALL_RAW}" "${CALL_RESULT}" + mcp_post "${CALL_REQ}" "${CALL_RAW}" + extract_tool_result "${CALL_RAW}" "${CALL_RESULT}" + +else + # ─────────────────────────────── OpenAI call ──────────────────────────── + python3 - "${SELECTED_TOOL}" "${ARGS_FILE}" "${CALL_RAW}" "${UI_URL}" <<'PY' +import json, sys, urllib.request, urllib.error +from pathlib import Path + +tool = sys.argv[1] +args = json.load(open(sys.argv[2])) if Path(sys.argv[2]).exists() else {} +out_raw = Path(sys.argv[3]) +ui_url = sys.argv[4] + +url = f"{ui_url}/v1/tools/{tool}/invoke" +payload = json.dumps({"arguments": args}).encode('utf-8') +req = urllib.request.Request( + url, data=payload, + headers={'Content-Type': 'application/json', 'Accept': 'application/json'}, + method='POST', +) +try: + with urllib.request.urlopen(req, timeout=120) as resp: + out_raw.write_bytes(resp.read()) +except urllib.error.HTTPError as exc: + out_raw.write_text( + json.dumps({'ok': False, 'error': f'HTTP {exc.code}: {exc.reason}'}), + encoding='utf-8', + ) +PY + + # Normalise the OpenAI /v1/tools/{name}/invoke response to the same shape + # as an MCP tools/call result so the display block below is mode-agnostic. + python3 - "${CALL_RAW}" "${CALL_RESULT}" <<'PY' +import json, sys +from pathlib import Path + +raw = Path(sys.argv[1]).read_text(encoding='utf-8').strip() +out = Path(sys.argv[2]) + +def write(obj): + out.write_text(json.dumps(obj, indent=2, ensure_ascii=False), encoding='utf-8') + +if not raw: + write({'ok': False, 'error': 'Empty response from OpenAI endpoint'}) + sys.exit(0) + +try: + body = json.loads(raw) +except Exception as exc: + write({'ok': False, 'error': f'Could not parse response: {exc}', 'raw': raw[:200]}) + sys.exit(0) + +if body.get('is_error'): + content = body.get('content', []) + msg = content[0].get('text', str(content)) if content else str(body) + write({'ok': False, 'error': msg}) + sys.exit(0) + +# Success — extract text content or pass through the full content array. +content = body.get('content', []) +if not content: + write({'ok': True, **{k: v for k, v in body.items() if k not in ('type', 'content', 'is_error')}}) + sys.exit(0) + +first = content[0] if isinstance(content, list) else content +if isinstance(first, dict) and first.get('type') == 'text': + try: + write(json.loads(first['text'])) + except (json.JSONDecodeError, TypeError): + write({'ok': True, 'text': first['text']}) +else: + write({'ok': True, 'content': content}) +PY +fi # end MODE branch sep printf ' Result:\n\n' @@ -624,16 +787,34 @@ info "Listing files produced in the mcpproxy files directory" FILES_DATA="${TMP_DIR}/files_data.json" -# One Python block handles all MCP calls (listfiles + one getfile per entry) -# so we avoid bash escaping issues with arbitrary file names. -python3 - "${MCP_URL}" "${SESSION_ID:-}" "$(( _RPC_ID + 1 ))" "${FILES_DATA}" <<'PY' +# Call mcpproxy__listfiles + mcpproxy__getfile using whichever protocol was +# selected. The MCP path uses the existing JSON-RPC session; the OpenAI path +# uses the /v1/tools/{name}/invoke REST endpoint. +if [[ "${MODE}" == "1" ]]; then + _FILES_MODE="mcp" + _FILES_MCP_URL="${MCP_URL}" + _FILES_SESSION="${SESSION_ID:-}" + _FILES_RPC_START="$(( _RPC_ID + 1 ))" + _FILES_UI_URL="" +else + _FILES_MODE="openai" + _FILES_MCP_URL="" + _FILES_SESSION="" + _FILES_RPC_START="0" + _FILES_UI_URL="${UI_URL}" +fi + +python3 - "${_FILES_MODE}" "${_FILES_MCP_URL}" "${_FILES_SESSION}" \ + "${_FILES_RPC_START}" "${FILES_DATA}" "${_FILES_UI_URL}" <<'PY' import json, sys, urllib.request, urllib.error from pathlib import Path -mcp_url = sys.argv[1] -session_id = sys.argv[2] -rpc_id = int(sys.argv[3]) -out_path = Path(sys.argv[4]) +mode = sys.argv[1] # 'mcp' or 'openai' +mcp_url = sys.argv[2] +session_id = sys.argv[3] +rpc_id = int(sys.argv[4]) +out_path = Path(sys.argv[5]) +ui_url = sys.argv[6] def _mcp_call(method, params): @@ -667,6 +848,35 @@ def _mcp_call(method, params): return {} +def _openai_call(tool_name, arguments): + """Call a tool via the OpenAI-compatible /v1/tools/{name}/invoke endpoint.""" + url = f"{ui_url}/v1/tools/{tool_name}/invoke" + payload = json.dumps({'arguments': arguments}).encode('utf-8') + req = urllib.request.Request( + url, data=payload, + headers={'Content-Type': 'application/json', 'Accept': 'application/json'}, + method='POST', + ) + try: + with urllib.request.urlopen(req, timeout=120) as resp: + body = json.loads(resp.read().decode('utf-8')) + # Translate to an MCP-shaped result so _extract() works for both paths. + if body.get('is_error'): + content = body.get('content', []) + msg = content[0].get('text', str(content)) if content else str(body) + return {'error': {'message': msg}} + return {'result': {'content': body.get('content', [])}} + except urllib.error.HTTPError as exc: + return {'error': {'code': exc.code, 'message': str(exc)}} + + +def _call_tool(tool_name, arguments): + if mode == 'mcp': + return _mcp_call('tools/call', {'name': tool_name, 'arguments': arguments}) + else: + return _openai_call(tool_name, arguments) + + def _extract(rpc): """Unwrap a tools/call JSON-RPC response to its payload dict.""" if 'error' in rpc: @@ -687,7 +897,7 @@ def _extract(rpc): # 1. Call mcpproxy__listfiles (root directory) -listing = _extract(_mcp_call('tools/call', {'name': 'mcpproxy__listfiles', 'arguments': {}})) +listing = _extract(_call_tool('mcpproxy__listfiles', {})) entries = listing.get('entries', []) base_dir = listing.get('base_dir', '.playwright-mcp') @@ -698,7 +908,7 @@ for entry in entries: continue fname = entry['name'] file_result = _extract( - _mcp_call('tools/call', {'name': 'mcpproxy__getfile', 'arguments': {'path': fname}}) + _call_tool('mcpproxy__getfile', {'path': fname}) ) files_fetched.append({ 'name': fname, diff --git a/tests/test_openai_api.py b/tests/test_openai_api.py new file mode 100644 index 0000000..2c6e26d --- /dev/null +++ b/tests/test_openai_api.py @@ -0,0 +1,272 @@ +"""Tests for the OpenAI-compatible /v1/tools endpoints in frontend/app.py. + +These endpoints are a pure addition — they share the tool_registry with server.py +but don't touch the MCP protocol at all. + +Each test pre-populates tool_registry directly (then clears it in teardown) and +creates a fresh TestClient against the app. No server.py import side-effects are +involved because we never register tools via server.register_tool() here. +""" +import json +from pathlib import Path +from typing import Any +from unittest.mock import AsyncMock + +import pytest +from fastapi.testclient import TestClient + +import tool_registry +from frontend.app import create_app + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +@pytest.fixture(autouse=True) +def clean_registry(): + """Ensure a clean tool_registry before and after every test.""" + tool_registry.clear() + yield + tool_registry.clear() + + +@pytest.fixture() +def app(tmp_path: Path): + return create_app( + config_dir=tmp_path / "tools", + env_file=tmp_path / ".env", + ) + + +@pytest.fixture() +def client(app): + return TestClient(app) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _register_tool( + name: str, + description: str = "A test tool", + input_schema: dict | None = None, + handler=None, +): + """Register a fake tool in the registry.""" + if input_schema is None: + input_schema = { + "type": "object", + "properties": { + "msg": {"type": "string", "description": "A message"}, + }, + "required": ["msg"], + } + if handler is None: + handler = AsyncMock(return_value={"ok": True, "echo": "hello"}) + spec = { + "name": name, + "description": description, + "input_schema": input_schema, + } + tool_registry.register(name, spec, handler) + return handler + + +# --------------------------------------------------------------------------- +# GET /v1/tools +# --------------------------------------------------------------------------- + +class TestListOpenAITools: + def test_empty_registry_returns_empty_list(self, client): + resp = client.get("/v1/tools") + assert resp.status_code == 200 + body = resp.json() + assert body == {"tools": []} + + def test_single_tool_returned_in_openai_schema(self, client): + _register_tool("myprov__ping", description="Ping the server") + resp = client.get("/v1/tools") + assert resp.status_code == 200 + tools = resp.json()["tools"] + assert len(tools) == 1 + tool = tools[0] + assert tool["type"] == "function" + fn = tool["function"] + assert fn["name"] == "myprov__ping" + assert fn["description"] == "Ping the server" + assert "parameters" in fn + + def test_parameters_match_input_schema(self, client): + schema = { + "type": "object", + "properties": { + "url": {"type": "string", "description": "The URL"}, + "timeout": {"type": "integer"}, + }, + "required": ["url"], + } + _register_tool("browser__navigate", input_schema=schema) + resp = client.get("/v1/tools") + fn = resp.json()["tools"][0]["function"] + params = fn["parameters"] + assert params["properties"]["url"]["type"] == "string" + assert params["properties"]["timeout"]["type"] == "integer" + assert "url" in params["required"] + + def test_multiple_tools_all_returned(self, client): + _register_tool("p__a", description="Tool A") + _register_tool("p__b", description="Tool B") + _register_tool("p__c", description="Tool C") + resp = client.get("/v1/tools") + names = {t["function"]["name"] for t in resp.json()["tools"]} + assert names == {"p__a", "p__b", "p__c"} + + def test_tool_with_no_input_schema_gets_empty_schema(self, client): + """A spec without input_schema should produce a valid (empty) parameters object.""" + tool_registry.register( + "bare__tool", + {"name": "bare__tool", "description": "no schema"}, + AsyncMock(return_value="ok"), + ) + resp = client.get("/v1/tools") + assert resp.status_code == 200 + fn = resp.json()["tools"][0]["function"] + params = fn["parameters"] + assert params["type"] == "object" + assert params["properties"] == {} + assert params["required"] == [] + + def test_response_structure_has_required_fields(self, client): + _register_tool("p__x") + body = client.get("/v1/tools").json() + assert "tools" in body + for t in body["tools"]: + assert t["type"] == "function" + assert "function" in t + assert "name" in t["function"] + assert "description" in t["function"] + assert "parameters" in t["function"] + + +# --------------------------------------------------------------------------- +# POST /v1/tools/{tool_name}/invoke +# --------------------------------------------------------------------------- + +class TestInvokeOpenAITool: + + # ── 404 when tool not found ────────────────────────────────────────────── + + def test_unknown_tool_returns_404(self, client): + resp = client.post("/v1/tools/does_not_exist/invoke", json={"arguments": {}}) + assert resp.status_code == 404 + + # ── Successful invocations ─────────────────────────────────────────────── + + @pytest.mark.asyncio + async def test_handler_called_with_arguments(self, client): + handler = AsyncMock(return_value={"ok": True}) + _register_tool("p__echo", handler=handler) + client.post("/v1/tools/p__echo/invoke", json={"arguments": {"msg": "hi"}}) + handler.assert_awaited_once() + _, kwargs = handler.call_args + assert kwargs.get("msg") == "hi" + + @pytest.mark.asyncio + async def test_ctx_passed_as_none(self, client): + """The handler must receive ctx=None (first positional arg).""" + received_ctx = [] + + async def capturing_handler(ctx, **kwargs): + received_ctx.append(ctx) + return {"ok": True} + + tool_registry.register( + "p__ctx_check", + {"name": "p__ctx_check", "description": "x", "input_schema": {"type": "object", "properties": {}, "required": []}}, + capturing_handler, + ) + client.post("/v1/tools/p__ctx_check/invoke", json={"arguments": {}}) + assert received_ctx == [None] + + def test_success_response_shape(self, client): + _register_tool("p__t", handler=AsyncMock(return_value={"ok": True})) + resp = client.post("/v1/tools/p__t/invoke", json={"arguments": {"msg": "x"}}) + assert resp.status_code == 200 + body = resp.json() + assert body["type"] == "tool_result" + assert body["is_error"] is False + assert isinstance(body["content"], list) + + # ── Result normalisation ───────────────────────────────────────────────── + + def test_string_result_wrapped_in_content_array(self, client): + _register_tool("p__t", handler=AsyncMock(return_value="hello world")) + body = client.post("/v1/tools/p__t/invoke", json={"arguments": {"msg": "x"}}).json() + assert body["content"] == [{"type": "text", "text": "hello world"}] + assert body["is_error"] is False + + def test_dict_with_content_key_passed_through(self, client): + content_val = [{"type": "text", "text": "rich result"}] + _register_tool("p__t", handler=AsyncMock(return_value={"content": content_val, "extra": 1})) + body = client.post("/v1/tools/p__t/invoke", json={"arguments": {"msg": "x"}}).json() + assert body["content"] == content_val + + def test_dict_without_content_key_serialised_as_text(self, client): + _register_tool("p__t", handler=AsyncMock(return_value={"ok": True, "data": [1, 2, 3]})) + body = client.post("/v1/tools/p__t/invoke", json={"arguments": {"msg": "x"}}).json() + assert len(body["content"]) == 1 + parsed = json.loads(body["content"][0]["text"]) + assert parsed == {"ok": True, "data": [1, 2, 3]} + + def test_list_result_used_as_content_directly(self, client): + content = [{"type": "text", "text": "a"}, {"type": "text", "text": "b"}] + _register_tool("p__t", handler=AsyncMock(return_value=content)) + body = client.post("/v1/tools/p__t/invoke", json={"arguments": {"msg": "x"}}).json() + assert body["content"] == content + + # ── Error handling ─────────────────────────────────────────────────────── + + def test_handler_exception_returns_is_error_true(self, client): + async def bad_handler(ctx, **kwargs): + raise ValueError("something exploded") + + tool_registry.register( + "p__bad", + {"name": "p__bad", "description": "x", "input_schema": {"type": "object", "properties": {}, "required": []}}, + bad_handler, + ) + resp = client.post("/v1/tools/p__bad/invoke", json={"arguments": {}}) + assert resp.status_code == 200 + body = resp.json() + assert body["type"] == "tool_result" + assert body["is_error"] is True + assert "something exploded" in body["content"][0]["text"] + + def test_handler_exception_does_not_return_500(self, client): + """Errors are surfaced as tool results (HTTP 200), not server errors.""" + async def raising(ctx, **kwargs): + raise RuntimeError("oops") + + tool_registry.register( + "p__raise", + {"name": "p__raise", "description": "x", "input_schema": {"type": "object", "properties": {}, "required": []}}, + raising, + ) + resp = client.post("/v1/tools/p__raise/invoke", json={"arguments": {}}) + assert resp.status_code == 200 + + def test_empty_arguments_body_accepted(self, client): + _register_tool("p__t", handler=AsyncMock(return_value="ok")) + resp = client.post("/v1/tools/p__t/invoke", json={}) + assert resp.status_code == 200 + + def test_missing_body_treated_as_no_arguments(self, client): + """Sending no body (or a non-JSON body) should be treated as {} arguments + rather than crashing — the endpoint is lenient about the request body.""" + _register_tool("p__t", handler=AsyncMock(return_value="ok")) + resp = client.post("/v1/tools/p__t/invoke") + assert resp.status_code == 200 + body = resp.json() + assert body["is_error"] is False diff --git a/tests/test_server.py b/tests/test_server.py index a8f42ec..5d38fa0 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -34,6 +34,7 @@ run_provider_setup, tool_is_enabled, ) +import tool_registry # --------------------------------------------------------------------------- @@ -681,3 +682,67 @@ def test_disabled_code_tool_skipped_without_loading_handler(self, tmp_path: Path } names = self._capture_registered(spec) assert names == ["p__alive"] + + +# --------------------------------------------------------------------------- +# tool_registry module +# --------------------------------------------------------------------------- + +class TestToolRegistry: + """Tests for tool_registry.py — the shared in-process tool store.""" + + def setup_method(self): + """Start each test with a clean registry.""" + tool_registry.clear() + + def teardown_method(self): + """Leave registry clean after each test.""" + tool_registry.clear() + + def test_get_empty_registry_returns_none(self): + assert tool_registry.get("nonexistent") is None + + def test_get_all_empty_registry_returns_empty_dict(self): + assert tool_registry.get_all() == {} + + def test_register_and_get(self): + spec = {"name": "t", "description": "desc", "input_schema": {}} + handler = lambda: None + tool_registry.register("myprov__t", spec, handler) + entry = tool_registry.get("myprov__t") + assert entry is not None + assert entry["spec"] is spec + assert entry["handler"] is handler + + def test_register_and_get_all(self): + spec1 = {"name": "a"} + spec2 = {"name": "b"} + h1 = lambda: "a" + h2 = lambda: "b" + tool_registry.register("p__a", spec1, h1) + tool_registry.register("p__b", spec2, h2) + all_tools = tool_registry.get_all() + assert set(all_tools.keys()) == {"p__a", "p__b"} + assert all_tools["p__a"]["spec"] is spec1 + assert all_tools["p__b"]["spec"] is spec2 + + def test_register_overwrites_existing_name(self): + spec_old = {"name": "t", "description": "old"} + spec_new = {"name": "t", "description": "new"} + tool_registry.register("p__t", spec_old, lambda: None) + tool_registry.register("p__t", spec_new, lambda: None) + entry = tool_registry.get("p__t") + assert entry["spec"]["description"] == "new" + + def test_get_all_returns_copy_not_reference(self): + tool_registry.register("p__t", {"name": "t"}, lambda: None) + snapshot = tool_registry.get_all() + tool_registry.clear() + # The snapshot must still contain the entry even after clearing the registry + assert "p__t" in snapshot + + def test_clear_empties_registry(self): + tool_registry.register("p__t", {}, lambda: None) + tool_registry.clear() + assert tool_registry.get_all() == {} + assert tool_registry.get("p__t") is None diff --git a/tool_registry.py b/tool_registry.py new file mode 100644 index 0000000..4b8f3d8 --- /dev/null +++ b/tool_registry.py @@ -0,0 +1,40 @@ +""" +Shared tool registry — populated by server.py at startup, read by frontend/app.py. + +server.py calls ``register()`` inside ``register_tool()`` for every enabled tool +(including the two built-ins). frontend/app.py reads ``get_all()`` / ``get()`` +to serve the OpenAI-compatible ``/v1/tools`` endpoints. + +Both modules run in the same Python process (the UI runs in a daemon thread), so +a plain module-level dict is sufficient — no IPC or shared-memory machinery needed. +""" + +from typing import Any, Callable + +# name → {"spec": tool_spec_dict, "handler": dynamic_tool_callable} +_registry: dict[str, dict[str, Any]] = {} + + +def register(name: str, spec: dict[str, Any], handler: Callable[..., Any]) -> None: + """Add or replace a tool in the registry. + + ``name`` — the advertised MCP tool name (e.g. ``playwright__browser_navigate``) + ``spec`` — the tool_spec dict (contains ``description``, ``input_schema``, …) + ``handler`` — the ``dynamic_tool`` async callable built in ``register_tool()`` + """ + _registry[name] = {"spec": spec, "handler": handler} + + +def get(name: str) -> dict[str, Any] | None: + """Return the registry entry for ``name``, or ``None`` if not found.""" + return _registry.get(name) + + +def get_all() -> dict[str, dict[str, Any]]: + """Return a shallow copy of the full registry (name → entry).""" + return dict(_registry) + + +def clear() -> None: + """Remove all entries. Used only in tests.""" + _registry.clear()