From 3a9e8e18df4c596360da54000f65e1bebc6039c2 Mon Sep 17 00:00:00 2001 From: tanaka takashi Date: Fri, 26 Jun 2026 23:54:17 +0900 Subject: MCP機能追加 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ollama_web/app.py | 22 +- src/ollama_web/llm.py | 4 +- src/ollama_web/mcp.py | 488 ++++++++++++++++++++++++++++++++++++ src/ollama_web/routes/chat.py | 34 ++- src/ollama_web/routes/mcp.py | 119 +++++++++ src/ollama_web/static/app.js | 2 +- src/ollama_web/static/mcp.js | 263 +++++++++++++++++++ src/ollama_web/static/style.css | 176 +++++++++++++ src/ollama_web/static/tools.js | 11 + src/ollama_web/templates/index.html | 23 +- src/ollama_web/tools/registry.py | 43 +++- 11 files changed, 1175 insertions(+), 10 deletions(-) create mode 100644 src/ollama_web/mcp.py create mode 100644 src/ollama_web/routes/mcp.py create mode 100644 src/ollama_web/static/mcp.js (limited to 'src') 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 = ` + + + `; + 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 @@ + @@ -61,7 +64,25 @@ + + + + - + \ 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}"}) -- cgit v1.2.3