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/static/mcp.js | 263 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 263 insertions(+) create mode 100644 src/ollama_web/static/mcp.js (limited to 'src/ollama_web/static/mcp.js') 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 -- cgit v1.2.3