summaryrefslogtreecommitdiff
path: root/src/ollama_web/routes/mcp.py
blob: 85d4bb3842a0a5e1ed1467ed5b89cb1b070d9a7d (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
"""MCP server configuration API endpoints."""

from __future__ import annotations

import logging
from typing import Any

from starlette.requests import Request
from starlette.responses import JSONResponse

from ..mcp import load_mcp_config, save_mcp_config

logger = logging.getLogger("ollama_web.mcp_route")


_ALLOWED_STDIO_FIELDS = {"command", "args", "env", "cwd", "encoding", "encoding_error_handler"}
_ALLOWED_HTTP_FIELDS = {"url", "headers", "timeout", "sse_read_timeout", "terminate_on_close"}


async def get_servers(request: Request) -> JSONResponse:
    """GET /api/mcp/servers: return the current MCP server configuration."""
    data_dir = request.app.state.settings.data_dir
    config = load_mcp_config(data_dir)
    return JSONResponse(config)


async def put_servers(request: Request) -> JSONResponse:
    """PUT /api/mcp/servers: persist a new MCP server configuration.

    The body must be a JSON object with a ``mcpServers`` key. Each server is
    validated to contain only recognized fields and a single transport.
    """
    try:
        body = await request.json()
    except Exception as exc:  # noqa: BLE001
        logger.warning("invalid MCP config JSON: %s", exc)
        return JSONResponse({"error": "Invalid JSON body"}, status_code=400)

    if not isinstance(body, dict) or "mcpServers" not in body:
        return JSONResponse(
            {"error": "Body must contain a 'mcpServers' object"},
            status_code=400,
        )

    raw_servers = body["mcpServers"]
    if not isinstance(raw_servers, dict):
        return JSONResponse(
            {"error": "'mcpServers' must be an object"},
            status_code=400,
        )

    cleaned: dict[str, Any] = {"mcpServers": {}}
    errors: list[str] = []
    for name, server in raw_servers.items():
        if not isinstance(server, dict):
            errors.append(f"Server '{name}' must be an object")
            continue

        if "command" in server:
            valid = _allowed_fields(server, _ALLOWED_STDIO_FIELDS, name, errors)
            if valid:
                cleaned["mcpServers"][name] = _clean_stdio_server(server)
        elif "url" in server:
            valid = _allowed_fields(server, _ALLOWED_HTTP_FIELDS, name, errors)
            if valid:
                cleaned["mcpServers"][name] = _clean_http_server(server)
        else:
            errors.append(
                f"Server '{name}' must define either 'command' (stdio) or 'url' (http)"
            )

    if errors:
        return JSONResponse({"error": errors}, status_code=400)

    data_dir = request.app.state.settings.data_dir
    save_mcp_config(data_dir, cleaned)
    return JSONResponse(cleaned)


def _allowed_fields(
    server: dict[str, Any],
    allowed: set[str],
    name: str,
    errors: list[str],
) -> bool:
    unknown = set(server.keys()) - allowed
    if unknown:
        errors.append(f"Server '{name}' has unknown fields: {', '.join(sorted(unknown))}")
        return False
    return True


def _clean_stdio_server(server: dict[str, Any]) -> dict[str, Any]:
    out: dict[str, Any] = {"command": str(server["command"])}
    if "args" in server:
        out["args"] = [str(a) for a in server["args"]]
    if "env" in server:
        out["env"] = {str(k): str(v) for k, v in server["env"].items()}
    if "cwd" in server and server["cwd"]:
        out["cwd"] = str(server["cwd"])
    for key in ("encoding", "encoding_error_handler"):
        if key in server:
            out[key] = str(server[key])
    return out


def _clean_http_server(server: dict[str, Any]) -> dict[str, Any]:
    out: dict[str, Any] = {"url": str(server["url"])}
    if "headers" in server:
        out["headers"] = {str(k): str(v) for k, v in server["headers"].items()}
    for key in ("timeout", "sse_read_timeout"):
        if key in server:
            try:
                out[key] = float(server[key])
            except (TypeError, ValueError):
                pass
    if "terminate_on_close" in server:
        out["terminate_on_close"] = bool(server["terminate_on_close"])
    return out