summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/ollama_web/app.py22
-rw-r--r--src/ollama_web/llm.py4
-rw-r--r--src/ollama_web/mcp.py488
-rw-r--r--src/ollama_web/routes/chat.py34
-rw-r--r--src/ollama_web/routes/mcp.py119
-rw-r--r--src/ollama_web/static/app.js2
-rw-r--r--src/ollama_web/static/mcp.js263
-rw-r--r--src/ollama_web/static/style.css176
-rw-r--r--src/ollama_web/static/tools.js11
-rw-r--r--src/ollama_web/templates/index.html23
-rw-r--r--src/ollama_web/tools/registry.py43
11 files changed, 1175 insertions, 10 deletions
diff --git a/src/ollama_web/app.py b/src/ollama_web/app.py
index 946d280..d0b85d4 100644
--- a/src/ollama_web/app.py
+++ b/src/ollama_web/app.py
@@ -2,6 +2,7 @@
from __future__ import annotations
+import logging
from pathlib import Path
from starlette.applications import Starlette
@@ -15,6 +16,7 @@ from .config import settings
from .middleware import SecurityHeadersMiddleware
from .routes import auth as auth_route
from .routes import chat as chat_route
+from .routes import mcp as mcp_route
from .routes import models as models_route
from .routes import pages as pages_route
from .routes import sessions as sessions_route
@@ -25,6 +27,17 @@ _STATIC_DIR = Path(__file__).resolve().parent / "static"
def create_app() -> Starlette:
"""Build and return the configured Starlette application."""
+ # Ensure application logs are written alongside uvicorn access logs.
+ log_path = Path(settings.data_dir).resolve().parent / "server.log"
+ log_path.parent.mkdir(parents=True, exist_ok=True)
+ handler = logging.FileHandler(str(log_path), encoding="utf-8")
+ handler.setFormatter(
+ logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")
+ )
+ root = logging.getLogger()
+ root.setLevel(logging.INFO)
+ root.addHandler(handler)
+
routes = [
Route("/login", pages_route.login, name="login_page"),
Route("/api/auth/login", auth_route.login, methods=["POST"], name="auth_login"),
@@ -77,6 +90,13 @@ def create_app() -> Starlette:
methods=["DELETE"],
name="clear_messages",
),
+ Route("/api/mcp/servers", mcp_route.get_servers, methods=["GET"], name="mcp_servers"),
+ Route(
+ "/api/mcp/servers",
+ mcp_route.put_servers,
+ methods=["PUT"],
+ name="mcp_servers_update",
+ ),
Mount("/static", app=StaticFiles(directory=str(_STATIC_DIR)), name="static"),
]
@@ -99,4 +119,4 @@ def create_app() -> Starlette:
return app
-app = create_app()
+app = create_app() \ No newline at end of file
diff --git a/src/ollama_web/llm.py b/src/ollama_web/llm.py
index 461cef9..0ab00a3 100644
--- a/src/ollama_web/llm.py
+++ b/src/ollama_web/llm.py
@@ -191,7 +191,7 @@ def chat_with_tools(
caps = capabilities or set()
raw_client = get_client(host)
reg = registry or default_registry()
- tools: list[ToolCallable] = [cast(Any, c) for c in reg.callables]
+ tools: list[ToolCallable] = [cast(Any, t) for t in reg.ollama_tools]
effective_tools = tools if "tools" in caps else []
effective_think = think if "thinking" in caps else None
@@ -342,7 +342,7 @@ def stream_chat_with_tools(
caps = capabilities or set()
raw_client = get_client(host)
reg = registry or default_registry()
- tools: list[ToolCallable] = [cast(Any, c) for c in reg.callables]
+ tools: list[ToolCallable] = [cast(Any, t) for t in reg.ollama_tools]
effective_tools = tools if "tools" in caps else []
effective_think = think if "thinking" in caps else None
diff --git a/src/ollama_web/mcp.py b/src/ollama_web/mcp.py
new file mode 100644
index 0000000..6cd3cc9
--- /dev/null
+++ b/src/ollama_web/mcp.py
@@ -0,0 +1,488 @@
+"""MCP client integration for ollama-web.
+
+This module loads MCP server configuration from ``data/mcpServers.json`` and
+bridges remote MCP tools into the local ``ToolRegistry`` used by the ollama
+tool-calling loop.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import json
+import logging
+from collections.abc import Callable
+from pathlib import Path
+from typing import Any
+
+from .config import settings
+
+logger = logging.getLogger("ollama_web.mcp")
+
+DEFAULT_TIMEOUT = 30.0
+
+_MCP_TOOL_PREFIX = "mcp__"
+
+
+def mcp_servers_file(data_dir: str | Path | None = None) -> Path:
+ """Return the path to ``mcpServers.json`` under the configured data dir."""
+ return Path(data_dir or settings.data_dir).resolve() / "mcpServers.json"
+
+
+def load_mcp_config(data_dir: str | Path | None = None) -> dict[str, Any]:
+ """Load the MCP server configuration from disk.
+
+ Returns:
+ A dict with a ``mcpServers`` key, or an empty default config when the
+ file does not exist or is malformed.
+ """
+ path = mcp_servers_file(data_dir)
+ if not path.exists():
+ return {"mcpServers": {}}
+ try:
+ data = json.loads(path.read_text(encoding="utf-8"))
+ except Exception as exc: # noqa: BLE001
+ logger.warning("failed to read %s: %s", path, exc)
+ return {"mcpServers": {}}
+
+ if not isinstance(data, dict):
+ return {"mcpServers": {}}
+ servers = data.get("mcpServers", {})
+ if not isinstance(servers, dict):
+ return {"mcpServers": {}}
+ return {"mcpServers": servers}
+
+
+def save_mcp_config(
+ data_dir: str | Path | None = None,
+ config: dict[str, Any] | None = None,
+) -> None:
+ """Persist the MCP server configuration to disk.
+
+ Args:
+ data_dir: Data directory root. Defaults to ``settings.data_dir``.
+ config: Configuration dict; must contain a ``mcpServers`` key.
+ """
+ path = mcp_servers_file(data_dir)
+ path.parent.mkdir(parents=True, exist_ok=True)
+ config = config or {"mcpServers": {}}
+ if "mcpServers" not in config:
+ config = {"mcpServers": {}}
+ path.write_text(
+ json.dumps(config, ensure_ascii=False, indent=2),
+ encoding="utf-8",
+ )
+
+
+def _is_stdio(server: dict[str, Any]) -> bool:
+ """Return whether the server definition describes a stdio transport."""
+ return "command" in server
+
+
+def _is_http(server: dict[str, Any]) -> bool:
+ """Return whether the server definition describes an HTTP transport."""
+ return "url" in server
+
+
+def _normalized_tool_name(server_name: str, tool_name: str) -> str:
+ """Build a unique ollama-visible tool name for an MCP tool."""
+ return f"{_MCP_TOOL_PREFIX}{server_name}__{tool_name}"
+
+
+def _parse_tool_name(full_name: str) -> tuple[str, str] | None:
+ """Recover ``(server_name, tool_name)`` from a normalized MCP tool name."""
+ if not full_name.startswith(_MCP_TOOL_PREFIX):
+ return None
+ rest = full_name[len(_MCP_TOOL_PREFIX) :]
+ parts = rest.split("__", 1)
+ if len(parts) != 2:
+ return None
+ return parts[0], parts[1]
+
+
+def _to_ollama_tool(server_name: str, tool: Any) -> dict[str, Any]:
+ """Convert an MCP ``Tool`` object into an ollama-compatible tool dict.
+
+ The resulting dict uses the OpenAI-compatible function-calling shape that
+ ollama accepts as an element of the ``tools`` argument.
+ """
+ name = getattr(tool, "name", "")
+ description = getattr(tool, "description", "") or name
+ schema = getattr(tool, "inputSchema", None) or {}
+
+ return {
+ "type": "function",
+ "function": {
+ "name": _normalized_tool_name(server_name, str(name)),
+ "description": str(description),
+ "parameters": _clean_schema(schema),
+ },
+ }
+
+
+def _clean_schema(schema: Any) -> dict[str, Any]:
+ """Return a JSON schema suitable for ollama, removing unsupported keys."""
+ if not isinstance(schema, dict):
+ return {"type": "object", "properties": {}, "required": []}
+
+ cleaned: dict[str, Any] = {
+ "type": schema.get("type", "object"),
+ "properties": {},
+ "required": [],
+ }
+ properties = schema.get("properties")
+ if isinstance(properties, dict):
+ cleaned["properties"] = properties
+ required = schema.get("required")
+ if isinstance(required, list):
+ cleaned["required"] = [str(r) for r in required]
+
+ # Preserve additional metadata ollama understands, if present.
+ for key in ("additionalProperties", "anyOf", "oneOf", "enum"):
+ if key in schema:
+ cleaned[key] = schema[key]
+ return cleaned
+
+
+async def _list_tools_for_server(
+ server_name: str,
+ server: dict[str, Any],
+) -> list[dict[str, Any]]:
+ """Connect to a single MCP server and return its tools in ollama form.
+
+ Exceptions are caught and logged so that one misbehaving server does not
+ break the whole tool registry.
+ """
+ tools: list[dict[str, Any]] = []
+ if _is_stdio(server):
+ tools = await _list_tools_stdio(server_name, server)
+ elif _is_http(server):
+ tools = await _list_tools_http(server_name, server)
+ else:
+ logger.warning("MCP server %s has no recognized transport", server_name)
+ return tools
+
+
+async def _list_tools_stdio(
+ server_name: str,
+ server: dict[str, Any],
+) -> list[dict[str, Any]]:
+ """List tools from a stdio MCP server."""
+ from mcp import ClientSession
+ from mcp.client.stdio import StdioServerParameters, stdio_client
+
+ command = str(server["command"])
+ args = [str(a) for a in server.get("args", [])]
+ env = server.get("env")
+ cwd = server.get("cwd")
+
+ params = StdioServerParameters(
+ command=command,
+ args=args,
+ env=env,
+ cwd=cwd,
+ )
+
+ async with stdio_client(params) as (read_stream, write_stream):
+ async with ClientSession(read_stream, write_stream) as session:
+ await session.initialize()
+ result = await session.list_tools()
+ return [_to_ollama_tool(server_name, t) for t in getattr(result, "tools", [])]
+
+
+async def _list_tools_http(
+ server_name: str,
+ server: dict[str, Any],
+) -> list[dict[str, Any]]:
+ """List tools from an HTTP (streamable) MCP server."""
+ import httpx
+ from mcp import ClientSession
+ from mcp.client.streamable_http import streamable_http_client
+
+ url = str(server["url"])
+ headers: dict[str, str] | None = None
+ raw_headers = server.get("headers")
+ if isinstance(raw_headers, dict):
+ headers = {str(k): str(v) for k, v in raw_headers.items()}
+
+ timeout = server.get("timeout", DEFAULT_TIMEOUT)
+ try:
+ timeout = float(timeout)
+ except (TypeError, ValueError):
+ timeout = DEFAULT_TIMEOUT
+
+ sse_read_timeout = server.get("sse_read_timeout", DEFAULT_TIMEOUT)
+ try:
+ sse_read_timeout = float(sse_read_timeout)
+ except (TypeError, ValueError):
+ sse_read_timeout = DEFAULT_TIMEOUT
+
+ client = httpx.AsyncClient(
+ headers=headers,
+ timeout=httpx.Timeout(timeout, read=sse_read_timeout),
+ )
+ async with client:
+ async with streamable_http_client(url, http_client=client) as (
+ read_stream,
+ write_stream,
+ _get_session_id,
+ ):
+ async with ClientSession(read_stream, write_stream) as session:
+ await session.initialize()
+ result = await session.list_tools()
+ return [_to_ollama_tool(server_name, t) for t in getattr(result, "tools", [])]
+
+
+async def collect_mcp_tools(
+ config: dict[str, Any] | None = None,
+) -> list[dict[str, Any]]:
+ """Collect ollama-compatible tool definitions from all configured MCP servers.
+
+ Args:
+ config: MCP configuration dict. If None, loaded from disk.
+
+ Returns:
+ A list of ollama tool dicts (``{"type": "function", "function": {...}}``).
+ """
+ if config is None:
+ config = load_mcp_config()
+
+ servers = config.get("mcpServers", {})
+ if not isinstance(servers, dict):
+ return []
+
+ tasks = [
+ _list_tools_for_server(name, server)
+ for name, server in servers.items()
+ if isinstance(server, dict)
+ ]
+ if not tasks:
+ return []
+
+ results = await asyncio.gather(*tasks, return_exceptions=True)
+ all_tools: list[dict[str, Any]] = []
+ for name, result in zip(servers.keys(), results, strict=True):
+ if isinstance(result, Exception):
+ logger.warning("failed to list tools from MCP server %s: %s", name, result)
+ continue
+ all_tools.extend(result)
+ return all_tools
+
+
+def collect_mcp_tools_sync(config: dict[str, Any] | None = None) -> list[dict[str, Any]]:
+ """Synchronous wrapper around ``collect_mcp_tools``.
+
+ Useful for callers running in a thread without an active event loop.
+ """
+ return asyncio.run(collect_mcp_tools(config))
+
+
+async def _call_tool_on_server(
+ server_name: str,
+ server: dict[str, Any],
+ tool_name: str,
+ arguments: dict[str, Any],
+) -> str:
+ """Call a single tool on the specified MCP server and return its result.
+
+ The result content is flattened into a JSON string.
+ """
+ if _is_stdio(server):
+ return await _call_tool_stdio(server_name, server, tool_name, arguments)
+ if _is_http(server):
+ return await _call_tool_http(server_name, server, tool_name, arguments)
+ raise ValueError(f"server {server_name} has no recognized transport")
+
+
+async def _call_tool_stdio(
+ server_name: str,
+ server: dict[str, Any],
+ tool_name: str,
+ arguments: dict[str, Any],
+) -> str:
+ from mcp import ClientSession
+ from mcp.client.stdio import StdioServerParameters, stdio_client
+
+ command = str(server["command"])
+ args = [str(a) for a in server.get("args", [])]
+ env = server.get("env")
+ cwd = server.get("cwd")
+
+ params = StdioServerParameters(
+ command=command,
+ args=args,
+ env=env,
+ cwd=cwd,
+ )
+
+ async with stdio_client(params) as (read_stream, write_stream):
+ async with ClientSession(read_stream, write_stream) as session:
+ await session.initialize()
+ result = await session.call_tool(tool_name, arguments)
+ return _format_tool_result(result)
+
+
+async def _call_tool_http(
+ server_name: str,
+ server: dict[str, Any],
+ tool_name: str,
+ arguments: dict[str, Any],
+) -> str:
+ import httpx
+ from mcp import ClientSession
+ from mcp.client.streamable_http import streamable_http_client
+
+ url = str(server["url"])
+ headers: dict[str, str] | None = None
+ raw_headers = server.get("headers")
+ if isinstance(raw_headers, dict):
+ headers = {str(k): str(v) for k, v in raw_headers.items()}
+
+ timeout = server.get("timeout", DEFAULT_TIMEOUT)
+ try:
+ timeout = float(timeout)
+ except (TypeError, ValueError):
+ timeout = DEFAULT_TIMEOUT
+
+ sse_read_timeout = server.get("sse_read_timeout", DEFAULT_TIMEOUT)
+ try:
+ sse_read_timeout = float(sse_read_timeout)
+ except (TypeError, ValueError):
+ sse_read_timeout = DEFAULT_TIMEOUT
+
+ client = httpx.AsyncClient(
+ headers=headers,
+ timeout=httpx.Timeout(timeout, read=sse_read_timeout),
+ )
+ async with client:
+ async with streamable_http_client(url, http_client=client) as (
+ read_stream,
+ write_stream,
+ _get_session_id,
+ ):
+ async with ClientSession(read_stream, write_stream) as session:
+ await session.initialize()
+ result = await session.call_tool(tool_name, arguments)
+ return _format_tool_result(result)
+
+
+def _format_tool_result(result: Any) -> str:
+ """Convert a ``CallToolResult`` into a JSON string for ollama."""
+ content: list[Any] = []
+ is_error = False
+ if isinstance(result, dict):
+ content = result.get("content", [])
+ is_error = bool(result.get("isError"))
+ else:
+ content = getattr(result, "content", [])
+ is_error = getattr(result, "isError", False)
+
+ parts: list[str] = []
+ for item in content:
+ if isinstance(item, dict):
+ item_type = item.get("type", "")
+ if item_type == "text":
+ parts.append(str(item.get("text", "")))
+ elif item_type == "image":
+ parts.append(f"[image/{item.get('mimeType', 'unknown')}]")
+ elif item_type == "resource":
+ resource = item.get("resource", {})
+ parts.append(_format_resource(resource))
+ else:
+ text = getattr(item, "text", None)
+ if text is not None:
+ parts.append(str(text))
+ else:
+ data = getattr(item, "data", None)
+ mime = getattr(item, "mimeType", None)
+ if data is not None:
+ parts.append(f"[image/{mime or 'unknown'}]")
+ else:
+ resource = getattr(item, "resource", None)
+ if resource is not None:
+ parts.append(_format_resource(resource))
+
+ body = "\n\n".join(parts)
+ if is_error:
+ return json.dumps({"error": body or "MCP tool returned an error"}, ensure_ascii=False)
+ return json.dumps({"result": body}, ensure_ascii=False)
+
+
+def _format_resource(resource: Any) -> str:
+ """Convert an embedded resource reference into a short text snippet."""
+ if isinstance(resource, dict):
+ uri = resource.get("uri", "")
+ mime = resource.get("mimeType", "")
+ text = resource.get("text", "")
+ if text:
+ return f"[resource {uri} ({mime})]\n{text}"
+ return f"[resource {uri} ({mime})]"
+ return f"[resource {resource}]"
+
+
+def call_mcp_tool_sync(
+ config: dict[str, Any] | None,
+ full_name: str,
+ arguments: dict[str, Any] | str = "",
+) -> str:
+ """Synchronous wrapper for calling an MCP tool by its normalized name.
+
+ This is registered as the executor in ``ToolRegistry`` so that the
+ synchronous ollama tool loop can invoke remote MCP tools.
+
+ Args:
+ config: MCP configuration dict. If None, loaded from disk.
+ full_name: Normalized MCP tool name (``mcp__server__tool``).
+ arguments: Tool arguments as a dict or JSON string.
+
+ Returns:
+ A JSON-encoded result string, or an error object on failure.
+ """
+ parsed = _parse_tool_name(full_name)
+ if parsed is None:
+ return json.dumps({"error": f"Invalid MCP tool name: {full_name}"}, ensure_ascii=False)
+
+ server_name, tool_name = parsed
+
+ if config is None:
+ config = load_mcp_config()
+ server = config.get("mcpServers", {}).get(server_name)
+ if not isinstance(server, dict):
+ return json.dumps(
+ {"error": f"MCP server not found for tool: {full_name}"}, ensure_ascii=False
+ )
+
+ if isinstance(arguments, str):
+ try:
+ arguments = json.loads(arguments) if arguments else {}
+ except json.JSONDecodeError:
+ return json.dumps(
+ {"error": f"Invalid JSON arguments for {full_name}: {arguments}"},
+ ensure_ascii=False,
+ )
+
+ try:
+ return asyncio.run(_call_tool_on_server(server_name, server, tool_name, arguments))
+ except Exception as exc: # noqa: BLE001
+ logger.warning("MCP tool %s execution failed: %s", full_name, exc)
+ return json.dumps({"error": f"{type(exc).__name__}: {exc}"}, ensure_ascii=False)
+
+
+def make_mcp_executor(
+ config: dict[str, Any] | None = None,
+) -> Callable[..., object]:
+ """Return a callable that executes an MCP tool by normalized name.
+
+ The returned callable accepts ``full_name`` (injected by the registry) and
+ optionally ``arguments`` (ollama may pass extra keyword arguments).
+ """
+ if config is None:
+ config = load_mcp_config()
+
+ def executor(
+ full_name: str,
+ arguments: dict[str, Any] | str = "",
+ **_kwargs: Any,
+ ) -> str:
+ return call_mcp_tool_sync(config, full_name, arguments)
+
+ return executor \ No newline at end of file
diff --git a/src/ollama_web/routes/chat.py b/src/ollama_web/routes/chat.py
index 8b5c3f4..75aabd7 100644
--- a/src/ollama_web/routes/chat.py
+++ b/src/ollama_web/routes/chat.py
@@ -16,6 +16,7 @@ from starlette.requests import Request
from starlette.responses import StreamingResponse
from ..config import settings
+from ..mcp import collect_mcp_tools, make_mcp_executor
from ..sessions import SessionStore, is_valid_id
from ..tools.fetch import pop_fetched_files
from ..tools.helper.image import resize_image
@@ -57,8 +58,8 @@ def _parse_messages(raw: list[dict[str, Any]]) -> list[Message]:
return out
-def _session_aware_registry(session_id: str | None) -> ToolRegistry:
- """Return a registry whose search_and_fetch receives the session id."""
+async def _build_registry(session_id: str | None) -> ToolRegistry:
+ """Return a registry with built-in tools and any configured MCP tools."""
reg = default_registry()
original = reg.get("search_and_fetch")
@@ -73,6 +74,24 @@ def _session_aware_registry(session_id: str | None) -> ToolRegistry:
reg._tools["search_and_fetch"] = wrapped # noqa: SLF001
+ try:
+ mcp_tools = await collect_mcp_tools()
+ except Exception as exc: # noqa: BLE001
+ logger.warning("failed to collect MCP tools: %s", exc)
+ mcp_tools = []
+
+ tool_names = [t.get("function", {}).get("name") for t in mcp_tools]
+ logger.info("collected %d MCP tool(s): %s", len(mcp_tools), tool_names)
+
+ if mcp_tools:
+ executor = make_mcp_executor()
+ for tool_def in mcp_tools:
+ fn = tool_def.get("function", {})
+ name = fn.get("name")
+ if not name:
+ continue
+ reg.register_mcp_tool(name, tool_def, executor)
+
return reg
@@ -188,7 +207,7 @@ async def _chat_event_stream(
from ollama_web import llm # local import to avoid circular at module load
- registry = _session_aware_registry(session_id)
+ registry = await _build_registry(session_id)
total_image_bytes = sum(
len(img.value) for img in (getattr(messages[-1], "images", None) or [])
@@ -209,6 +228,13 @@ async def _chat_event_stream(
sorted(capabilities),
)
+ # Fallback: some cloud/proxy models support tools even though ollama
+ # does not advertise the capability. Enable tools when the model name
+ # hints at a cloud endpoint and tools are not already reported.
+ if "tools" not in capabilities and ":cloud" in model:
+ capabilities.add("tools")
+ logger.info("enabled tools capability fallback for cloud model %s", model)
+
async for event in llm.astream_chat_with_tools(
messages, model, think=think, host=host, registry=registry,
capabilities=capabilities,
@@ -304,4 +330,4 @@ async def chat(request: Request) -> StreamingResponse:
async def _single_event(data: str) -> AsyncIterator[bytes]:
"""Yield a single SSE event and finish."""
- yield f"data: {data}\n\n".encode()
+ yield f"data: {data}\n\n".encode() \ No newline at end of file
diff --git a/src/ollama_web/routes/mcp.py b/src/ollama_web/routes/mcp.py
new file mode 100644
index 0000000..85d4bb3
--- /dev/null
+++ b/src/ollama_web/routes/mcp.py
@@ -0,0 +1,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 \ No newline at end of file
diff --git a/src/ollama_web/static/app.js b/src/ollama_web/static/app.js
index b9da89f..4c22f3f 100644
--- a/src/ollama_web/static/app.js
+++ b/src/ollama_web/static/app.js
@@ -188,7 +188,7 @@ function addMessage(role) {
function addToolBubble(parentExtras, name, args) {
const det = document.createElement("details");
det.className = "tool";
- const label = (TOOLS.find((t) => t.name === name) || { label: name }).label;
+ const label = typeof window.toolLabel === "function" ? window.toolLabel(name) : name;
const sum = document.createElement("summary");
sum.textContent = `${label} を実行中…`;
det.appendChild(sum);
diff --git a/src/ollama_web/static/mcp.js b/src/ollama_web/static/mcp.js
new file mode 100644
index 0000000..ff3a27b
--- /dev/null
+++ b/src/ollama_web/static/mcp.js
@@ -0,0 +1,263 @@
+"use strict";
+
+(function () {
+ const modal = document.getElementById("mcp-modal");
+ const openBtn = document.getElementById("mcp-settings-btn");
+ const closeBtn = document.getElementById("mcp-modal-close");
+ const addBtn = document.getElementById("mcp-add-btn");
+ const saveBtn = document.getElementById("mcp-save-btn");
+ const serversEl = document.getElementById("mcp-servers");
+
+ let currentConfig = { mcpServers: {} };
+
+ function csrfHeaders(headers = {}) {
+ const token = document.cookie.split("; ").find((row) => row.startsWith("ollama_web_csrf="));
+ if (!token) return headers;
+ return { ...headers, "X-CSRF-Token": decodeURIComponent(token.split("=")[1]) };
+ }
+
+ async function csrfFetch(url, options = {}) {
+ const method = (options.method || "GET").toUpperCase();
+ const headers = ["POST", "PUT", "PATCH", "DELETE"].includes(method)
+ ? csrfHeaders(options.headers || {})
+ : options.headers;
+ const res = await fetch(url, { ...options, headers });
+ if (res.status === 401) {
+ window.location.assign(`/login?next=${encodeURIComponent(window.location.pathname)}`);
+ }
+ return res;
+ }
+
+ function createTextarea(value, placeholder, rows) {
+ const el = document.createElement("textarea");
+ el.rows = rows || 2;
+ el.placeholder = placeholder || "";
+ el.value = value || "";
+ return el;
+ }
+
+ function createInput(value, placeholder, type) {
+ const el = document.createElement("input");
+ el.type = type || "text";
+ el.placeholder = placeholder || "";
+ el.value = value || "";
+ return el;
+ }
+
+ function renderServer(name, server) {
+ const card = document.createElement("div");
+ card.className = "mcp-server-card";
+
+ const header = document.createElement("div");
+ header.className = "mcp-server-header";
+
+ const nameInput = createInput(name, "サーバー名", "text");
+ nameInput.className = "mcp-server-name";
+
+ const delBtn = document.createElement("button");
+ delBtn.type = "button";
+ delBtn.className = "mcp-server-delete";
+ delBtn.textContent = "削除";
+ delBtn.addEventListener("click", () => card.remove());
+
+ header.append(nameInput, delBtn);
+
+ const typeSelect = document.createElement("select");
+ typeSelect.className = "mcp-server-type";
+ typeSelect.innerHTML = `
+ <option value="stdio">stdio</option>
+ <option value="http">HTTP Stream</option>
+ `;
+ const isHttp = "url" in server;
+ typeSelect.value = isHttp ? "http" : "stdio";
+
+ const fieldsEl = document.createElement("div");
+ fieldsEl.className = "mcp-server-fields";
+
+ function wrapField(label, control, required) {
+ const wrap = document.createElement("label");
+ wrap.className = "mcp-field";
+ const span = document.createElement("span");
+ span.textContent = `${label} ${required ? "(必須)" : "(任意)"}`;
+ wrap.append(span, control);
+ return wrap;
+ }
+
+ function renderFields() {
+ fieldsEl.innerHTML = "";
+ const type = typeSelect.value;
+ if (type === "stdio") {
+ const command = createInput(server.command || "", "例: npx", "text");
+ command.dataset.key = "command";
+ const args = createTextarea(
+ (server.args || []).join("\n"),
+ "例: -y @modelcontextprotocol/server-filesystem /tmp",
+ 3
+ );
+ args.dataset.key = "args";
+ const env = createTextarea(
+ Object.entries(server.env || {})
+ .map(([k, v]) => `${k}=${v}`)
+ .join("\n"),
+ "例: API_KEY=sk-xxx",
+ 3
+ );
+ env.dataset.key = "env";
+ const cwd = createInput(server.cwd || "", "例: C:\\Users\\Home", "text");
+ cwd.dataset.key = "cwd";
+
+ fieldsEl.append(
+ wrapField("command", command, true),
+ wrapField("args", args, false),
+ wrapField("env", env, false),
+ wrapField("cwd", cwd, false)
+ );
+ } else {
+ const url = createInput(server.url || "", "例: http://127.0.0.1:9000/mcp", "text");
+ url.dataset.key = "url";
+ const headers = createTextarea(
+ Object.entries(server.headers || {})
+ .map(([k, v]) => `${k}=${v}`)
+ .join("\n"),
+ "例: Authorization=Bearer xxx",
+ 3
+ );
+ headers.dataset.key = "headers";
+ const timeout = createInput(
+ server.timeout !== undefined ? String(server.timeout) : "",
+ "例: 30",
+ "number"
+ );
+ timeout.dataset.key = "timeout";
+
+ fieldsEl.append(
+ wrapField("url", url, true),
+ wrapField("headers", headers, false),
+ wrapField("timeout", timeout, false)
+ );
+ }
+ }
+
+ typeSelect.addEventListener("change", () => {
+ server = isHttp ? { url: "" } : { command: "" };
+ renderFields();
+ });
+
+ card.append(header, typeSelect, fieldsEl);
+ renderFields();
+ return card;
+ }
+
+ function parseServer(card) {
+ const name = card.querySelector(".mcp-server-name").value.trim();
+ if (!name) return null;
+
+ const type = card.querySelector(".mcp-server-type").value;
+ const fields = card.querySelectorAll(".mcp-server-fields [data-key]");
+ const server = {};
+
+ for (const field of fields) {
+ const key = field.dataset.key;
+ const value = field.value.trim();
+ if (!value) continue;
+
+ if (type === "stdio") {
+ if (key === "command") server.command = value;
+ if (key === "args") server.args = value.split("\n").map((s) => s.trim()).filter(Boolean);
+ if (key === "env") {
+ server.env = {};
+ for (const line of value.split("\n")) {
+ const idx = line.indexOf("=");
+ if (idx > 0) server.env[line.slice(0, idx).trim()] = line.slice(idx + 1).trim();
+ }
+ }
+ if (key === "cwd") server.cwd = value;
+ } else {
+ if (key === "url") server.url = value;
+ if (key === "headers") {
+ server.headers = {};
+ for (const line of value.split("\n")) {
+ const idx = line.indexOf("=");
+ if (idx > 0) server.headers[line.slice(0, idx).trim()] = line.slice(idx + 1).trim();
+ }
+ }
+ if (key === "timeout") server.timeout = parseFloat(value);
+ }
+ }
+
+ return { name, server };
+ }
+
+ async function loadConfig() {
+ try {
+ const res = await fetch("/api/mcp/servers");
+ if (!res.ok) throw new Error(res.statusText);
+ currentConfig = await res.json();
+ } catch (err) {
+ console.error("failed to load MCP config", err);
+ currentConfig = { mcpServers: {} };
+ }
+ render();
+ }
+
+ function render() {
+ serversEl.innerHTML = "";
+ const servers = currentConfig.mcpServers || {};
+ for (const [name, server] of Object.entries(servers)) {
+ if (server && typeof server === "object") {
+ serversEl.appendChild(renderServer(name, server));
+ }
+ }
+ }
+
+ function openModal() {
+ loadConfig();
+ modal.classList.add("open");
+ }
+
+ function closeModal() {
+ modal.classList.remove("open");
+ }
+
+ openBtn.addEventListener("click", openModal);
+ closeBtn.addEventListener("click", closeModal);
+ modal.addEventListener("click", (e) => {
+ if (e.target === modal) closeModal();
+ });
+
+ addBtn.addEventListener("click", () => {
+ const card = renderServer("new_server", { command: "" });
+ serversEl.appendChild(card);
+ });
+
+ saveBtn.addEventListener("click", async () => {
+ const cards = serversEl.querySelectorAll(".mcp-server-card");
+ const servers = {};
+ for (const card of cards) {
+ const parsed = parseServer(card);
+ if (parsed) servers[parsed.name] = parsed.server;
+ }
+
+ const payload = { mcpServers: servers };
+ try {
+ const res = await csrfFetch("/api/mcp/servers", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(payload),
+ });
+ if (!res.ok) {
+ const data = await res.json().catch(() => ({}));
+ alert(`保存に失敗しました: ${(data.error || []).join(" / ") || res.statusText}`);
+ return;
+ }
+ currentConfig = await res.json();
+ alert("MCP設定を保存しました");
+ closeModal();
+ } catch (err) {
+ console.error("failed to save MCP config", err);
+ alert("MCP設定の保存に失敗しました");
+ }
+ });
+
+ loadConfig();
+})(); \ No newline at end of file
diff --git a/src/ollama_web/static/style.css b/src/ollama_web/static/style.css
index ae7f775..8a21d84 100644
--- a/src/ollama_web/static/style.css
+++ b/src/ollama_web/static/style.css
@@ -544,3 +544,179 @@ button#send-btn { padding: 0 20px; }
margin: 0;
color: var(--danger);
}
+
+/* MCP settings */
+.sidebar-footer {
+ padding: 10px 14px;
+ border-top: 1px solid var(--border);
+}
+
+.sidebar-footer button {
+ width: 100%;
+ background: var(--bg);
+ color: var(--text);
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ padding: 8px;
+ cursor: pointer;
+ font-size: 14px;
+}
+
+.sidebar-footer button:hover {
+ border-color: var(--accent);
+}
+
+.modal {
+ display: none;
+ position: fixed;
+ inset: 0;
+ background: rgba(0, 0, 0, 0.7);
+ z-index: 300;
+ align-items: center;
+ justify-content: center;
+ padding: 16px;
+}
+
+.modal.open {
+ display: flex;
+}
+
+.modal-content {
+ background: var(--panel);
+ border: 1px solid var(--border);
+ border-radius: 10px;
+ width: min(720px, 100%);
+ max-height: 90vh;
+ display: flex;
+ flex-direction: column;
+}
+
+.modal-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 14px 16px;
+ border-bottom: 1px solid var(--border);
+}
+
+.modal-header h2 {
+ margin: 0;
+ font-size: 16px;
+}
+
+.modal-header .close-btn {
+ background: transparent;
+ border: none;
+ color: var(--muted);
+ font-size: 22px;
+ cursor: pointer;
+ line-height: 1;
+}
+
+.modal-header .close-btn:hover {
+ color: var(--text);
+}
+
+.modal-body {
+ padding: 16px;
+ overflow-y: auto;
+ flex: 1;
+}
+
+.modal-help {
+ margin: 0 0 14px;
+ font-size: 13px;
+ color: var(--muted);
+ line-height: 1.5;
+}
+
+.modal-help code {
+ background: var(--bg);
+ border: 1px solid var(--border);
+ border-radius: 4px;
+ padding: 1px 4px;
+ font-size: 12px;
+}
+
+.modal-footer {
+ display: flex;
+ justify-content: flex-end;
+ padding: 12px 16px;
+ border-top: 1px solid var(--border);
+ gap: 8px;
+}
+
+.modal-footer button {
+ padding: 6px 16px;
+}
+
+.modal-footer button.secondary {
+ background: transparent;
+}
+
+.mcp-server-card {
+ background: var(--bg);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ padding: 12px;
+ margin-bottom: 12px;
+}
+
+.mcp-server-header {
+ display: flex;
+ gap: 8px;
+ margin-bottom: 10px;
+}
+
+.mcp-server-name {
+ flex: 1;
+}
+
+.mcp-server-delete {
+ background: transparent;
+ color: var(--danger);
+ border-color: var(--danger);
+}
+
+.mcp-server-delete:hover {
+ border-color: var(--danger);
+ background: rgba(248, 81, 73, 0.1);
+}
+
+.mcp-server-type {
+ width: 100%;
+ margin-bottom: 10px;
+}
+
+.mcp-field {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ margin-bottom: 10px;
+}
+
+.mcp-field span {
+ font-size: 13px;
+ color: var(--muted);
+}
+
+.mcp-field input,
+.mcp-field textarea {
+ background: var(--panel);
+ color: var(--text);
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ padding: 8px;
+ font-size: 14px;
+ font-family: inherit;
+ width: 100%;
+}
+
+.mcp-field textarea {
+ resize: vertical;
+}
+
+#mcp-add-btn {
+ width: 100%;
+ background: transparent;
+}
diff --git a/src/ollama_web/static/tools.js b/src/ollama_web/static/tools.js
index b15587e..59d5c02 100644
--- a/src/ollama_web/static/tools.js
+++ b/src/ollama_web/static/tools.js
@@ -6,3 +6,14 @@ window.TOOLS = [
{ name: "search_and_fetch", label: "ファイル検索・取得" },
{ name: "pdf_to_text", label: "PDFテキスト変換" },
];
+
+window.toolLabel = function toolLabel(name) {
+ const builtin = window.TOOLS.find((t) => t.name === name);
+ if (builtin) return builtin.label;
+
+ const mcpMatch = /^mcp__([^_]+)__(.+)$/.exec(name);
+ if (mcpMatch) {
+ return `${mcpMatch[2]} (${mcpMatch[1]})`;
+ }
+ return name;
+};
diff --git a/src/ollama_web/templates/index.html b/src/ollama_web/templates/index.html
index 2095e74..f59066d 100644
--- a/src/ollama_web/templates/index.html
+++ b/src/ollama_web/templates/index.html
@@ -19,6 +19,9 @@
<button id="new-session-btn" type="button" title="新規セッション">+</button>
</div>
<ul id="session-list"></ul>
+ <div class="sidebar-footer">
+ <button id="mcp-settings-btn" type="button">MCP設定</button>
+ </div>
</aside>
<div id="sidebar-backdrop"></div>
@@ -61,7 +64,25 @@
</div>
</form>
</div>
+
+ <div id="mcp-modal" class="modal">
+ <div class="modal-content">
+ <div class="modal-header">
+ <h2>MCPサーバー設定</h2>
+ <button id="mcp-modal-close" type="button" class="close-btn">&times;</button>
+ </div>
+ <div class="modal-body">
+ <div id="mcp-servers"></div>
+ <button id="mcp-add-btn" type="button" class="secondary">+ サーバー追加</button>
+ </div>
+ <div class="modal-footer">
+ <button id="mcp-save-btn" type="button">保存</button>
+ </div>
+ </div>
+ </div>
+
<script src="{{ url_for('static', path='tools.js') }}"></script>
+ <script src="{{ url_for('static', path='mcp.js') }}"></script>
<script src="{{ url_for('static', path='app.js') }}"></script>
</body>
-</html>
+</html> \ No newline at end of file
diff --git a/src/ollama_web/tools/registry.py b/src/ollama_web/tools/registry.py
index 2b6c7ae..17c755d 100644
--- a/src/ollama_web/tools/registry.py
+++ b/src/ollama_web/tools/registry.py
@@ -5,6 +5,7 @@ from __future__ import annotations
import json
from collections.abc import Callable, Sequence
from dataclasses import dataclass, field
+from typing import Any
from .fetch import search_and_fetch
from .pdf import pdf_to_text
@@ -17,12 +18,35 @@ class ToolRegistry:
"""A simple registry mapping tool names to callable implementations."""
_tools: dict[str, Callable[..., object]] = field(default_factory=dict)
+ _ollama_tool_defs: list[dict[str, Any]] = field(default_factory=list)
+ _mcp_tool_names: set[str] = field(default_factory=set)
def register(self, func: Callable[..., object]) -> Callable[..., object]:
"""Register a callable under its ``__name__``."""
self._tools[func.__name__] = func
return func
+ def register_mcp_tool(
+ self,
+ name: str,
+ tool_def: dict[str, Any],
+ executor: Callable[..., object],
+ ) -> Callable[..., object]:
+ """Register a remote MCP tool under a normalized name.
+
+ Args:
+ name: The normalized ollama-visible tool name.
+ tool_def: The ollama-compatible tool definition dict.
+ executor: A callable that accepts ``(full_name, arguments)`` and
+ returns a JSON-encoded result string.
+ """
+ self._tools[name] = executor
+ self._mcp_tool_names.add(name)
+ # Avoid duplicate definitions if a registry is rebuilt.
+ self._ollama_tool_defs = [d for d in self._ollama_tool_defs if d.get("name") != name]
+ self._ollama_tool_defs.append(tool_def)
+ return executor
+
@property
def names(self) -> list[str]:
return list(self._tools.keys())
@@ -31,6 +55,19 @@ class ToolRegistry:
def callables(self) -> list[Callable[..., object]]:
return list(self._tools.values())
+ @property
+ def ollama_tools(self) -> list[dict[str, Any] | Callable[..., object]]:
+ """Return a list suitable for ollama's ``tools`` argument.
+
+ Built-in tools are passed as their callables so ollama can infer schemas
+ from docstrings. MCP tools are passed as pre-built dict definitions.
+ """
+ # Only return built-in callables and dict definitions; skip the MCP
+ # executor callables stored in the tools dict.
+ return [
+ func for name, func in self._tools.items() if name not in self._mcp_tool_names
+ ] + self._ollama_tool_defs
+
def get(self, name: str) -> Callable[..., object] | None:
return self._tools.get(name)
@@ -57,7 +94,11 @@ class ToolRegistry:
return json.dumps({"error": f"Invalid JSON arguments for {name}: {arguments}"})
try:
- result = func(**arguments)
+ if name in self._mcp_tool_names:
+ # MCP executor expects (full_name, arguments).
+ result = func(name, arguments)
+ else:
+ result = func(**arguments)
except Exception as exc: # noqa: BLE001
return json.dumps({"error": f"{type(exc).__name__}: {exc}"})