// panes/tools.js — MCP tool browser & invoker with schema-driven forms. import { registerPane } from '../app.js'; import { mcp, escapeHtml } from '../lib/api.js'; import { fmtJSON, ok, err, copyToClipboard, inputFromSchema, collectSchemaForm, historyStore } from '../lib/ui.js'; const callHist = historyStore('cx_tool_calls', 30); const FAV_KEY = 'cx_tool_favs'; const favs = new Set(JSON.parse(localStorage.getItem(FAV_KEY) || '[]')); function persistFavs() { localStorage.setItem(FAV_KEY, JSON.stringify([...favs])); } const TPL = `
Tools
Discover and invoke MCP tools.
loading…

Select a tool

Choose a tool on the left.

`; let allTools = []; let current = null; let mode = 'form'; let group = 'all'; function passes(t, q) { const blob = (t.name + ' ' + (t.description || '')).toLowerCase(); if (q && !blob.includes(q)) return false; if (group === 'fav' && !favs.has(t.name)) return false; if (group === 'recent') { const recents = new Set(callHist.list().map(r => r.tool)); if (!recents.has(t.name)) return false; } return true; } function renderList(host) { const q = host.querySelector('#tl-search').value.trim().toLowerCase(); const items = allTools.filter(t => passes(t, q)); host.querySelector('#tl-list').innerHTML = items.length ? items.map(t => ``).join('') : '
no tools match
'; host.querySelectorAll('[data-tool]').forEach(b => b.addEventListener('click', () => select(host, b.dataset.tool))); } function renderForm(host) { if (!current) return; const f = host.querySelector('#tl-form'); const schema = current.inputSchema || current.input_schema || {}; const props = schema.properties || {}; const names = Object.keys(props); if (!names.length) { f.innerHTML = '
no input schema — call with empty args or use JSON
'; return; } f.innerHTML = names.map(n => inputFromSchema(n, props[n])).join(''); } function renderHist(host) { const list = callHist.list().filter(r => r.tool === current?.name); host.querySelector('#tl-hist').innerHTML = list.length ? list.map((r, i) => ``).join('') : '
no recent calls
'; host.querySelectorAll('#tl-hist [data-hi]').forEach(b => b.addEventListener('click', () => { const r = list[+b.dataset.hi]; if (r) { host.querySelector('#tl-json').value = fmtJSON(r.args); switchMode(host, 'json'); } })); } function select(host, name) { current = allTools.find(t => t.name === name); if (!current) return; host.querySelector('#tl-name').textContent = current.name; host.querySelector('#tl-desc').textContent = current.description || '—'; host.querySelector('#tl-body').hidden = false; const favBtn = host.querySelector('#tl-fav'); favBtn.hidden = false; favBtn.textContent = favs.has(name) ? '★ unfavorite' : '☆ favorite'; host.querySelector('#tl-json').value = '{}'; renderForm(host); renderHist(host); } function switchMode(host, m) { mode = m; host.querySelectorAll('#tl-mode button').forEach(b => b.classList.toggle('active', b.dataset.m === m)); host.querySelector('#tl-form').hidden = m !== 'form'; host.querySelector('#tl-json').hidden = m !== 'json'; } function gatherArgs(host) { if (mode === 'form') return collectSchemaForm(host.querySelector('#tl-form')); const t = host.querySelector('#tl-json').value.trim(); if (!t) return {}; return JSON.parse(t); } async function load(host) { try { const j = await mcp.tools(); allTools = (j.tools || []).slice().sort((a, b) => a.name.localeCompare(b.name)); renderList(host); } catch (e) { host.querySelector('#tl-list').innerHTML = `
${escapeHtml(e.message)}
`; } } registerPane('tools', { label: 'Tools', init(host) { host.innerHTML = TPL; host.querySelector('#tl-refresh').addEventListener('click', () => load(host)); host.querySelector('#tl-search').addEventListener('input', () => renderList(host)); host.querySelectorAll('#tl-seg button').forEach(b => b.addEventListener('click', () => { host.querySelectorAll('#tl-seg button').forEach(x => x.classList.toggle('active', x === b)); group = b.dataset.g; renderList(host); })); host.querySelectorAll('#tl-mode button').forEach(b => b.addEventListener('click', () => switchMode(host, b.dataset.m))); host.querySelector('#tl-fav').addEventListener('click', () => { if (!current) return; if (favs.has(current.name)) favs.delete(current.name); else favs.add(current.name); persistFavs(); host.querySelector('#tl-fav').textContent = favs.has(current.name) ? '★ unfavorite' : '☆ favorite'; renderList(host); }); host.querySelector('#tl-copy-args').addEventListener('click', () => { try { copyToClipboard(fmtJSON(gatherArgs(host)), 'args copied'); } catch (e) { err(e.message); } }); host.querySelector('#tl-reset').addEventListener('click', () => { if (current) select(host, current.name); }); host.querySelector('#tl-call').addEventListener('click', async () => { if (!current) return; let args; try { args = gatherArgs(host); } catch (e) { return err('args: ' + e.message); } const t0 = performance.now(); const pre = host.querySelector('#tl-result'); pre.textContent = 'calling…'; try { const j = await mcp.call(current.name, args); const dt = (performance.now() - t0).toFixed(0); host.querySelector('#tl-meta').textContent = `${dt} ms`; pre.textContent = fmtJSON(j); callHist.push({ t: Date.now(), tool: current.name, args }); renderHist(host); ok('called ' + current.name); } catch (e) { pre.textContent = 'error: ' + e.message; err(e.message); } }); load(host); }, });