summaryrefslogtreecommitdiff
path: root/src/ollama_web/routes/mcp.py
blob: ba3628c846b8ad2c1cff2fa2b8fa6cf0d50458f8 (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
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
"""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,
    redact_secrets,
    save_mcp_config,
    validate_mcp_server_config,
    validate_mcp_server_name,
)

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(redact_secrets(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] = []
    data_dir = request.app.state.settings.data_dir
    for raw_name, server in raw_servers.items():
        try:
            name = validate_mcp_server_name(raw_name)
        except ValueError as exc:
            errors.append(f"Server '{raw_name}' invalid name: {exc}")
            continue

        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_server = _clean_stdio_server(server)
                try:
                    validate_mcp_server_config(name, cleaned_server, data_dir)
                except ValueError as exc:
                    errors.append(f"Server '{name}' invalid stdio config: {exc}")
                    continue
                cleaned["mcpServers"][name] = cleaned_server
        elif "url" in server:
            valid = _allowed_fields(server, _ALLOWED_HTTP_FIELDS, name, errors)
            if valid:
                cleaned_server = _clean_http_server(server)
                try:
                    validate_mcp_server_config(name, cleaned_server, data_dir)
                except ValueError as exc:
                    errors.append(f"Server '{name}' invalid http config: {exc}")
                    continue
                cleaned["mcpServers"][name] = cleaned_server
        else:
            errors.append(f"Server '{name}' must define either 'command' (stdio) or 'url' (http)")

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

    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